CVE-2026-12326
Overview
Background
- `SamplePool`
- GeckoView pool that hands out reusable
Sampleobjects backed bySharedMemorySampleBuffers for decoded media frames. - `OutputProcessor`
- inner class of
Codecthat pulls decoded output frames from the AndroidMediaCodecand copies them into pooledSamplebuffers. - `MediaCodec.getOutputBuffer(index).capacity()`
- Android API that, on modern devices, reports the valid data range of the current frame rather than the maximum buffer size.
- `info.size`
- the
MediaCodec.BufferInfofield giving the actual byte length of the current decoded frame being copied out.
Root Cause Analysis
In Codec.java, OutputProcessor.obtainOutputSample sized the SamplePool’s default buffer exactly once, gated by the mHasOutputCapacitySet flag, using getOutputBuffer(index).capacity() from the very first output frame. On modern Android that capacity() reflects only the current frame’s valid data range (e.g. ~28 bytes for the initial SPS/PPS config), so the pool’s mDefaultBufferSize was permanently locked to a tiny value. The invariant that a recycled Sample’s SampleBuffer capacity must be at least info.size before frame data is written into it was therefore violated: a later, larger frame could be copied into an undersized recycled buffer, corrupting SharedMemory.
The fix replaces the one-shot capacity hint with a monotonically growing mMaxObservedOutputSize driven by actual info.size, calling setOutputBufferSize before obtainOutput; SamplePool now sweeps and disposes stranded undersized recycled samples on growth and, in obtain, only returns a recycled sample whose buffer capacity meets the requested size (allocating a new one otherwise). This guarantees every handed-out buffer is large enough for the frame it will hold.
getOutputBuffer(index).capacity() as a stable maximum-buffer hint set once, when it actually reports the current frame’s data range, causing all pooled buffers to be sized to the tiny first frame; the fix drives buffer sizing from observed info.size and enforces a per-request capacity check so no frame is ever copied into a too-small buffer.Attack Path
- Serve crafted media
An attacker gets a Firefox/Thunderbird for Android user to load media that decodes through the hardware
MediaCodecpath (non-surface output). - Poison the pool size
The first output frame (small config data such as SPS/PPS) makes
capacity()tiny, andmHasOutputCapacitySetlocksmDefaultBufferSizeto that value. - Deliver an oversized frame
A subsequent decoded frame has
info.sizefar larger than the locked buffer capacity. - Overflow the recycled buffer
obtainOutputreturns a recycled undersizedSampleBuffer, and writinginfo.sizebytes overruns theSharedMemoryallocation, corrupting memory.
Impact Assessment
SharedMemory buffer overflow in the GeckoView media decoding path on Android, corrupting memory with attacker-influenced frame contents. Mozilla presumes such memory corruption could, with effort, be leveraged toward arbitrary code execution. The precondition is decoding attacker-supplied media through the non-mRenderToSurface output path where variable-size frames follow a small initial config frame.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java |
modified | |
ifmobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java |
modified |
Files Changed
mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.javamobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
Audit Directions
- Unreliable API return values as invariantsaudit any use of
capacity(), size, or length getters treated as stable maxima when the platform may return per-call, current-state values. - Once-set size flagsflag
boolean-gated one-time sizing (likemHasOutputCapacitySet) for pooled or shared buffers where inputs can later exceed the first observed size. - Recycled buffer reuse without capacity checksreview object/buffer pools that hand back recycled entries without verifying the buffer is large enough for the current request before writing into it.
Patch
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
index 4ae1f0c97e8..15dad9982d1 100644
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
@@ -244,7 +244,12 @@ import org.mozilla.gecko.gfx.GeckoSurface;
private class OutputProcessor {
private final boolean mRenderToSurface;
- private boolean mHasOutputCapacitySet;
+ // Tracks the largest output frame size observed so far. MediaCodec's
+ // getOutputBuffer(idx).capacity() is not reliable as a max-buffer hint
+ // on modern Android (it reports the valid data range of the current
+ // frame, e.g. ~28 bytes for the first SPS/PPS), so we grow the
+ // SamplePool's default monotonically from observed info.size instead.
+ private int mMaxObservedOutputSize;
private Queue<Output> mSentOutputs = new LinkedList<>();
private boolean mStopped;
@@ -285,6 +290,14 @@ import org.mozilla.gecko.gfx.GeckoSurface;
}
private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) {
+ // Grow the pool's minimum-useful capacity ahead of allocation so
+ // setDefaultBufferSize sweeps any now-stranded recycled samples
+ // before obtainOutput could hand one back.
+ if (!mRenderToSurface && info.size > mMaxObservedOutputSize) {
+ mMaxObservedOutputSize = info.size;
+ mSamplePool.setOutputBufferSize(mMaxObservedOutputSize);
+ }
+
final Sample sample = mSamplePool.obtainOutput(info);
if (mRenderToSurface) {
@@ -292,14 +305,6 @@ import org.mozilla.gecko.gfx.GeckoSurface;
}
final ByteBuffer output = mCodec.getOutputBuffer(index);
- if (!mHasOutputCapacitySet) {
- final int capacity = output.capacity();
- if (capacity > 0) {
- mSamplePool.setOutputBufferSize(capacity);
- mHasOutputCapacitySet = true;
- }
- }
-
if (info.size > 0) {
try {
mSamplePool
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
index a2101b3aebd..134e01154be 100644
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
@@ -8,6 +8,7 @@ import android.media.MediaCodec;
import android.util.SparseArray;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Iterator;
import java.util.List;
import org.mozilla.gecko.mozglue.SharedMemory;
@@ -26,23 +27,47 @@ final class SamplePool {
mBufferless = bufferless;
}
- private void setDefaultBufferSize(final int size) {
+ private synchronized void setDefaultBufferSize(final int size) {
if (mBufferless) {
throw new IllegalStateException("Setting buffer size of a bufferless pool is not allowed");
}
+ if (size <= mDefaultBufferSize) {
+ return;
+ }
mDefaultBufferSize = size;
+ // Sweep: any sample admitted under a smaller default is now stranded.
+ final Iterator<Sample> it = mRecycledSamples.iterator();
+ while (it.hasNext()) {
+ final Sample s = it.next();
+ final SampleBuffer buf = mBuffers.get(s.bufferId);
+ if (buf == null || buf.capacity() < mDefaultBufferSize) {
+ it.remove();
+ disposeSample(s);
+ }
+ }
}
private synchronized Sample obtain(final int size) {
- if (!mRecycledSamples.isEmpty()) {
- return mRecycledSamples.remove(0);
- }
-
if (mBufferless) {
+ if (!mRecycledSamples.isEmpty()) {
+ return mRecycledSamples.remove(0);
+ }
return Sample.obtain();
- } else {
- return allocateSampleAndBuffer(size);
}
+ final Iterator<Sample> it = mRecycledSamples.iterator();
+ while (it.hasNext()) {
+ final Sample candidate = it.next();
+ final SampleBuffer buf = mBuffers.get(candidate.bufferId);
+ if (buf != null && buf.capacity() >= size) {
+ it.remove();
+ return candidate;
+ }
+ it.remove();
+ if (buf != null) {
+ disposeSample(candidate);
+ }
+ }
+ return allocateSampleAndBuffer(size);
}
private Sample allocateSampleAndBuffer(final int size) {