From 0eea69b023a071b46ad12655796c2c12585b53bd Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 07:56:12 -0400 Subject: [PATCH] A contact could crash the app with one image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sending path was careful — read the bounds first, then decode sampled — but the two places that draw an image RECEIVED from a peer called `decodeByteArray` with no options at all, decoding at full size. Inbound attachments are capped at 12 MiB, and 12 MiB of a suitably chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded: OutOfMemoryError. The effect is not one crash. The decode happens every time the thread or the story is opened, so a single message wedges that conversation permanently from the outside, and the victim cannot get past it to delete anything. Both render paths now go through `ImagePrep.decodeBounded`, which reads the bounds before allocating — the same thing the send path has always done. The story composer's own draft goes through it too: a 50MP photo is just as much bitmap whoever picked it. ⚠️ A byte-count cap does not bound this. The 12 MiB limit was already in place and is exactly what made the attack convenient — the constraint that matters is the decoded dimensions, not the compressed size. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/ChatScreens.kt | 3 ++- .../io/flamenet/messenger/app/ImagePrep.kt | 24 +++++++++++++++++++ .../io/flamenet/messenger/app/StoriesFeed.kt | 6 +++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 3e514be..09995b9 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -312,7 +312,8 @@ internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: Messenger LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) if (bytes == null) { failed = true; return@LaunchedEffect } - bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + // Bounded: these bytes came from a peer. See ImagePrep.decodeBounded. + bitmap = ImagePrep.decodeBounded(bytes) if (bitmap == null) failed = true } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt index e8327e8..4c90bf4 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ImagePrep.kt @@ -84,6 +84,30 @@ object ImagePrep { } /** Decode with `inSampleSize`, so the full-size bitmap never exists. */ + /** + * Decode bytes that came from somebody else, bounded. + * + * 🔴 **The render paths were calling `decodeByteArray` with no options at + * all.** Sending was careful — bounds first, then a sampled decode — but + * the two places that draw an image RECEIVED from a peer decoded at full + * size. Inbound attachments are capped at 12 MiB, and 12 MiB of cleverly + * chosen JPEG is a few hundred megabytes of ARGB_8888 once expanded. A + * contact could send one image and crash the app every time the thread or + * the story was opened — a conversation wedged from the outside, with no + * way for the victim to get past it. + * + * ⚠️ Bounds are read first so an enormous picture is never fully decoded + * just to discover how big it is. That is the whole point; a size check on + * the byte count alone does not bound the decoded bitmap. + */ + fun decodeBounded(source: ByteArray, maxSide: Int = STANDARD_MAX_SIDE): Bitmap? { + if (source.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(source, 0, source.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + return decodeScaled(source, bounds, maxSide) + } + private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? { var sample = 1 while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt index 5c44a33..82725eb 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt @@ -223,7 +223,8 @@ fun StoryComposerDialog( if (draft.duration > 0) StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap() else - android.graphics.BitmapFactory.decodeByteArray(draft.bytes, 0, draft.bytes.size) + // Own draft, but a 50MP photo is just as much bitmap. + ImagePrep.decodeBounded(draft.bytes) ?.asImageBitmap() }.getOrNull() } @@ -439,7 +440,8 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView LaunchedEffect(ref.id) { val bytes = model.attachmentBytes(ref) if (bytes == null) { failed.value = true; return@LaunchedEffect } - bitmap.value = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + // Bounded: a received story's bytes are a peer's choice. + bitmap.value = ImagePrep.decodeBounded(bytes) if (bitmap.value == null) failed.value = true } val loaded = bitmap.value