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:
co-authored by
Claude Opus 4.8
parent
141f3168a5
commit
0eea69b023
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user