Bound pre-auth compressed payload expansion

This commit is contained in:
jack
2026-07-12 10:29:30 -04:00
parent b7f0b33d3a
commit fc9abffe0c
4 changed files with 509 additions and 47 deletions
@@ -324,21 +324,36 @@ object BinaryProtocol {
} }
} }
fun decode(data: ByteArray): BitchatPacket? { fun decode(data: ByteArray): BitchatPacket? =
decode(data, CompressionUtil::decompress)
/** Test seam used to prove rejected expansion sizes never reach inflation. */
internal fun decodeForTesting(
data: ByteArray,
decompress: (ByteArray, Int) -> ByteArray?
): BitchatPacket? = decode(data, decompress)
private fun decode(
data: ByteArray,
decompress: (ByteArray, Int) -> ByteArray?
): BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied) - iOS fix // Try decode as-is first (robust when padding wasn't applied) - iOS fix
decodeCore(data)?.let { return it } decodeCore(data, decompress)?.let { return it }
// If that fails, try after removing padding // If that fails, try after removing padding
val unpadded = MessagePadding.unpad(data) val unpadded = MessagePadding.unpad(data)
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
return decodeCore(unpadded) return decodeCore(unpadded, decompress)
} }
/** /**
* Core decoding implementation used by decode() with and without padding removal - iOS fix * Core decoding implementation used by decode() with and without padding removal - iOS fix
*/ */
private fun decodeCore(raw: ByteArray): BitchatPacket? { private fun decodeCore(
raw: ByteArray,
decompress: (ByteArray, Int) -> ByteArray?
): BitchatPacket? {
try { try {
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
@@ -435,23 +450,42 @@ object BinaryProtocol {
} else { } else {
buffer.getShort().toUShort().toInt() buffer.getShort().toUShort().toInt()
} }
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
if (originalSize <= 0 || originalSize > maxExpandedSize) {
Log.w(
"BinaryProtocol",
"Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize"
)
return null
}
// Compressed payload // Compressed payload
val compressedSize = payloadLength.toInt() - lengthFieldBytes val compressedSize = payloadLength.toInt() - lengthFieldBytes
if (compressedSize == 0) {
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
return null
}
val compressedPayload = ByteArray(compressedSize) val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload) buffer.get(compressedPayload)
// Security check: Compression bomb protection // Security check: Compression bomb protection
if (compressedSize > 0) { val ratio = originalSize.toDouble() / compressedSize.toDouble()
val ratio = originalSize.toDouble() / compressedSize.toDouble() if (ratio > 50_000.0) {
if (ratio > 50_000.0) { Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") return null
return null
}
} }
// Decompress // Decompress
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null val expandedPayload = decompress(compressedPayload, originalSize) ?: return null
if (expandedPayload.size != originalSize) {
Log.w(
"BinaryProtocol",
"Expanded payload size ${expandedPayload.size} did not match declared size $originalSize"
)
return null
}
expandedPayload
} else { } else {
val payloadBytes = ByteArray(payloadLength.toInt()) val payloadBytes = ByteArray(payloadLength.toInt())
buffer.get(payloadBytes) buffer.get(payloadBytes)
@@ -2,6 +2,7 @@ package com.bitchat.android.protocol
import android.util.Log import android.util.Log
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.zip.DataFormatException
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.Inflater import java.util.zip.Inflater
@@ -11,6 +12,10 @@ import java.util.zip.Inflater
*/ */
object CompressionUtil { object CompressionUtil {
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
// Inflation allocates the full declared output buffer. Keep that allocation single-flight so
// concurrent packets cannot multiply the bounded per-packet memory cost.
private val decompressionLock = Any()
/** /**
* Helper to check if compression is worth it - exact same logic as iOS * Helper to check if compression is worth it - exact same logic as iOS
@@ -73,49 +78,93 @@ object CompressionUtil {
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers) * iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
*/ */
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? { fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers) val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
try { if (compressedData.isEmpty()) {
val inflater = Inflater(true) // true = raw deflate, no headers Log.w("CompressionUtil", "Refusing an empty compressed payload")
inflater.setInput(compressedData) return null
}
val decompressedBuffer = ByteArray(originalSize) if (originalSize <= 0 || originalSize > maxExpandedSize) {
val actualSize = inflater.inflate(decompressedBuffer) Log.w(
inflater.end() "CompressionUtil",
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
// Verify decompressed size matches expected (same validation as iOS) )
return if (actualSize == originalSize) { return null
decompressedBuffer }
} else if (actualSize > 0) {
// Handle case where actual size is different return synchronized(decompressionLock) {
decompressedBuffer.copyOfRange(0, actualSize) // iOS COMPRESSION_ZLIB produces raw deflate format (no headers).
} else { val rawResult = try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (e: Exception) {
Log.d(
"CompressionUtil",
"Raw deflate decompression failed: ${e.message}"
)
null null
} }
} catch (e: Exception) {
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...") if (rawResult != null) {
rawResult
// Fallback: try with zlib headers in case of mixed usage } else {
try { // Fallback after either a format error or an incomplete/wrong-sized raw stream:
val inflater = Inflater(false) // false = expect zlib headers // accept the zlib-wrapped form used by some older/mixed clients, but only when it
inflater.setInput(compressedData) // independently satisfies the same exact-size and complete-stream checks.
try {
val decompressedBuffer = ByteArray(originalSize) inflateExact(compressedData, originalSize, nowrap = false)
val actualSize = inflater.inflate(decompressedBuffer) } catch (fallbackException: Exception) {
inflater.end() Log.e(
"CompressionUtil",
return if (actualSize == originalSize) { "Both raw deflate and zlib decompression failed: ${fallbackException.message}"
decompressedBuffer )
} else if (actualSize > 0) {
decompressedBuffer.copyOfRange(0, actualSize)
} else {
null null
} }
} catch (fallbackException: Exception) {
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
return null
} }
} }
} }
/**
* Inflate one complete stream into exactly [originalSize] bytes.
*
* A full output buffer alone is not success: an attacker can under-declare a larger stream so
* the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
* a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
* as are trailing bytes after the compressed stream.
*
* [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
* zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
* same bytes are a complete, exact-sized zlib stream before they can be accepted.
*/
@Throws(DataFormatException::class)
private fun inflateExact(
compressedData: ByteArray,
originalSize: Int,
nowrap: Boolean
): ByteArray? {
val inflater = Inflater(nowrap)
return try {
inflater.setInput(compressedData)
val output = ByteArray(originalSize)
var written = 0
while (written < originalSize) {
val count = inflater.inflate(output, written, originalSize - written)
if (count == 0) break
written += count
}
if (written != originalSize) return null
// Give Inflater one byte of room to consume the end marker. Any produced byte proves
// the declared size was smaller than the actual expansion.
val overflowProbe = ByteArray(1)
if (inflater.inflate(overflowProbe) != 0) return null
if (!inflater.finished() || inflater.remaining != 0) return null
output
} finally {
inflater.end()
}
}
/** /**
* Test function to verify deflate compression works correctly * Test function to verify deflate compression works correctly
@@ -1,5 +1,6 @@
package com.bitchat.android.protocol package com.bitchat.android.protocol
import com.bitchat.android.model.BitchatFilePacket
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.Random import java.util.Random
import java.util.zip.Deflater
class BinaryProtocolTest { class BinaryProtocolTest {
@@ -987,6 +990,309 @@ class BinaryProtocolTest {
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result) assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
} }
@Test
fun `v2 expanded payload at exact maximum passes bound without allocating output`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
val raw = compressedPacket(
version = 2u,
originalSize = max,
compressedData = ByteArray(256) { it.toByte() }
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
calls += 1
assertEquals(max, requestedSize)
null // Prove the boundary reached this seam without allocating a 10 MiB result.
}
assertNull("The test decompressor deliberately returns no payload", result)
assertEquals(1, calls)
}
@Test
fun `v2 expanded payload above maximum never reaches decompressor`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
val raw = compressedPacket(
version = 2u,
originalSize = max + 1,
compressedData = ByteArray(256) { it.toByte() }
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
byteArrayOf(0x42)
}
assertNull("An oversized expansion must be rejected before inflation", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `v2 negative expanded payload never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = -1,
compressedData = byteArrayOf(0x03)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
byteArrayOf(0x42)
}
assertNull("A negative expansion must be rejected before inflation", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `zero expanded payload never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = 0,
compressedData = rawDeflate(ByteArray(0))
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
ByteArray(0)
}
assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `empty compressed body never reaches decompressor`() {
val raw = compressedPacket(
version = 2u,
originalSize = 128,
compressedData = ByteArray(0)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
calls += 1
ByteArray(128)
}
assertNull("A compressed payload must contain deflate bytes", result)
assertEquals("The decompressor must not be invoked", 0, calls)
}
@Test
fun `v1 unsigned maximum expanded size reaches decompressor`() {
val originalSize = 0xFFFF
val raw = compressedPacket(
version = 1u,
originalSize = originalSize,
compressedData = byteArrayOf(0x01, 0x02)
)
var calls = 0
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
calls += 1
assertEquals(originalSize, requestedSize)
null
}
assertNull("The test decompressor deliberately returns no payload", result)
assertEquals(1, calls)
}
@Test
fun `compression utility rejects invalid expansion sizes directly`() {
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
assertNull(CompressionUtil.decompress(ByteArray(0), 1))
assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0))
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1))
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1))
}
@Test
fun `raw deflate expands only when size and stream completion are exact`() {
val payload = ByteArray(4_096) { index -> (index % 17).toByte() }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
)
assertNotNull(decoded)
assertArrayEquals(payload, decoded!!.payload)
}
@Test
fun `zlib wrapped payload remains compatible when size and stream completion are exact`() {
val payload = ByteArray(4_096) { index -> (index % 23).toByte() }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
)
assertNotNull(decoded)
assertArrayEquals(payload, decoded!!.payload)
}
@Test
fun `under-declared zlib expansion is rejected by fallback`() {
val payload = ByteArray(4_096) { 0x51 }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
)
assertNull("Zlib fallback must reject output beyond the declaration", decoded)
}
@Test
fun `over-declared zlib expansion is rejected by fallback`() {
val payload = ByteArray(128) { 0x52 }
val compressed = zlibDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
)
assertNull("Zlib fallback must reject output shorter than the declaration", decoded)
}
@Test
fun `truncated zlib stream is rejected by fallback`() {
val payload = ByteArray(4_096) { index -> (index % 29).toByte() }
val compressed = zlibDeflate(payload)
val truncated = compressed.copyOf(compressed.size - 1)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
)
assertNull("Zlib fallback must require the stream end marker and checksum", decoded)
}
@Test
fun `zlib stream with trailing bytes is rejected by fallback`() {
val payload = ByteArray(4_096) { index -> (index % 13).toByte() }
val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00)
val decoded = BinaryProtocol.decode(
compressedPacket(
version = 2u,
originalSize = payload.size,
compressedData = compressedWithTrailingByte
)
)
assertNull("Zlib fallback must consume the complete input and nothing more", decoded)
}
@Test
fun `under-declared raw expansion is rejected even when output buffer fills`() {
val payload = ByteArray(4_096) { 0x41 }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
)
assertNull("Inflater must be finished, not merely fill the declared buffer", decoded)
}
@Test
fun `over-declared raw expansion is rejected instead of returning a prefix`() {
val payload = ByteArray(128) { 0x42 }
val compressed = rawDeflate(payload)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
)
assertNull("The expanded byte count must equal the declaration", decoded)
}
@Test
fun `truncated raw stream is rejected even if all declared bytes were emitted`() {
val payload = ByteArray(4_096) { index -> (index % 31).toByte() }
val compressed = rawDeflate(payload)
val truncated = compressed.copyOf(compressed.size - 1)
val decoded = BinaryProtocol.decode(
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
)
assertNull("A stream without its end marker must not be accepted", decoded)
}
@Test
fun `raw stream with trailing bytes is rejected`() {
val payload = ByteArray(4_096) { index -> (index % 19).toByte() }
val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00)
val decoded = BinaryProtocol.decode(
compressedPacket(
version = 2u,
originalSize = payload.size,
compressedData = compressedWithTrailingByte
)
)
assertNull("Trailing bytes after a complete stream must not be accepted", decoded)
}
@Test
fun `decoder rejects a decompressor result shorter than its declaration`() {
val raw = compressedPacket(
version = 2u,
originalSize = 128,
compressedData = ByteArray(16) { it.toByte() }
)
val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) }
assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded)
}
@Test
fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() {
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
val filePayload = BitchatFilePacket(
fileName = "legacy-11m.bin",
fileSize = content.size.toLong(),
mimeType = "application/octet-stream",
content = content
).encode()
assertNotNull(filePayload)
val encoded = BinaryProtocol.encode(
BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = hexToBytes(senderHex),
recipientID = SpecialRecipients.BROADCAST,
timestamp = fixedTimestamp,
payload = filePayload!!,
ttl = 5u
),
padding = false
)
assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded)
assertTrue(
"The compressed wire body remains below the normal 10 MiB input cap",
encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
)
assertNull(
"The uniform bound intentionally rejects this legacy expansion until streaming admission exists",
BinaryProtocol.decode(encoded)
)
}
/** /**
* Compression bomb is rejected * Compression bomb is rejected
* *
@@ -1163,6 +1469,59 @@ class BinaryProtocolTest {
return result return result
} }
private fun compressedPacket(
version: UByte,
originalSize: Int,
compressedData: ByteArray,
type: UByte = MessageType.MESSAGE.value
): ByteArray {
val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2
val payloadLength = originalSizeFieldBytes + compressedData.size
val headerSize = if (version >= 2u.toUByte()) 16 else 14
val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply {
order(ByteOrder.BIG_ENDIAN)
put(version.toByte())
put(type.toByte())
put(5.toByte())
putLong(fixedTimestamp.toLong())
put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
if (version >= 2u.toUByte()) {
putInt(payloadLength)
} else {
putShort(payloadLength.toShort())
}
put(hexToBytes(senderHex))
if (version >= 2u.toUByte()) {
putInt(originalSize)
} else {
putShort(originalSize.toShort())
}
put(compressedData)
}
return buffer.array()
}
private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true)
private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false)
private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray {
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)
return try {
deflater.setInput(data)
deflater.finish()
val output = ByteArrayOutputStream()
val buffer = ByteArray(1_024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
output.write(buffer, 0, count)
}
output.toByteArray()
} finally {
deflater.end()
}
}
private fun makePacket( private fun makePacket(
version: UByte = 1u, version: UByte = 1u,
type: UByte = MessageType.MESSAGE.value, type: UByte = MessageType.MESSAGE.value,
+20
View File
@@ -91,6 +91,26 @@ PayloadLength: 4 bytes (big-endian, max ~4 GiB)
- Clients sending file transfers should preferentially use v2 format. - Clients sending file transfers should preferentially use v2 format.
- Fragmentation still applies: large files are split into fragments that fit within BLE MTU constraints (~128 KiB per fragment). - Fragmentation still applies: large files are split into fragments that fit within BLE MTU constraints (~128 KiB per fragment).
#### Compressed expansion rollout gate (draft/HOLD)
Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer
message type. It also requires a non-empty compressed body, an exact declared output size, and a
complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is
attacker-controlled before packet signature verification, and the current receive pipeline must
inflate before it can perform that verification.
This intentionally means a legacy Android sender can produce a highly compressible public file
between 10 MiB and the UI's 50 MiB send limit that the bounded decoder rejects. The hardening must
therefore remain a rollout HOLD rather than silently ship as backward compatible. Grandfathering
50 MiB also is not safe after only a type check: inflation allocates the declared payload and
`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
for a maximum-size transfer.
Before enabling that legacy range, receive processing needs an authenticated admission decision
made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or
another ownership-preserving design that avoids the second full-size copy). The sender limit and a
wire capability/version transition must then be coordinated so old and new clients fail predictably.
### 1.3 File Transfer TLV payload (BitchatFilePacket) ### 1.3 File Transfer TLV payload (BitchatFilePacket)
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently. The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.