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) {
|
LaunchedEffect(ref.id) {
|
||||||
val bytes = model.attachmentBytes(ref)
|
val bytes = model.attachmentBytes(ref)
|
||||||
if (bytes == null) { failed = true; return@LaunchedEffect }
|
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
|
if (bitmap == null) failed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ object ImagePrep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Decode with `inSampleSize`, so the full-size bitmap never exists. */
|
/** 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? {
|
private fun decodeScaled(source: ByteArray, bounds: BitmapFactory.Options, maxSide: Int): Bitmap? {
|
||||||
var sample = 1
|
var sample = 1
|
||||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2
|
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSide * 2) sample *= 2
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ fun StoryComposerDialog(
|
|||||||
if (draft.duration > 0)
|
if (draft.duration > 0)
|
||||||
StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap()
|
StoryMedia.posterFrame(draft.bytes, context)?.asImageBitmap()
|
||||||
else
|
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()
|
?.asImageBitmap()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -439,7 +440,8 @@ private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerView
|
|||||||
LaunchedEffect(ref.id) {
|
LaunchedEffect(ref.id) {
|
||||||
val bytes = model.attachmentBytes(ref)
|
val bytes = model.attachmentBytes(ref)
|
||||||
if (bytes == null) { failed.value = true; return@LaunchedEffect }
|
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
|
if (bitmap.value == null) failed.value = true
|
||||||
}
|
}
|
||||||
val loaded = bitmap.value
|
val loaded = bitmap.value
|
||||||
|
|||||||
Reference in New Issue
Block a user