A contact could crash the app with one image

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 07:56:12 -04:00
co-authored by Claude Opus 4.8
parent 141f3168a5
commit 0eea69b023
3 changed files with 30 additions and 3 deletions
@@ -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
}
@@ -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
@@ -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