High firefox Memory Corruption 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionMemory safety bugs present in Firefox 151 and Thunderbird 151. Some of these bugs showed evidence of memory corruption and we presume that with enough effort some of these could have been exploited to run arbitrary code.
ComponentCore
Bug ClassMemory Corruption
Tracker2039050
Fix commit1e7358dfe1f0 (firefox) +46/-16
CISA KEVNot listed
CreditedAshley Zebrowski, Christian Holler, Dan Baker, Jan de Mooij, Jon Coppeard, Maurice Dauer, Nicolas B. Pierron, Nika Layzell, Randell Jesup, Rob Wu, Ryan Hunt, Steve Fink, Tom Schuster, Tomoya Nakanishi, Yannis Juglaret, Serge Guelton and the Mozilla Fuzzing Team
Disclosed2026-06-16

Background

`SamplePool`
GeckoView pool that hands out reusable Sample objects backed by SharedMemory SampleBuffers for decoded media frames.
`OutputProcessor`
inner class of Codec that pulls decoded output frames from the Android MediaCodec and copies them into pooled Sample buffers.
`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.BufferInfo field 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.

Key insight
The core mistake was treating 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

  1. Serve crafted media An attacker gets a Firefox/Thunderbird for Android user to load media that decodes through the hardware MediaCodec path (non-surface output).
  2. Poison the pool size The first output frame (small config data such as SPS/PPS) makes capacity() tiny, and mHasOutputCapacitySet locks mDefaultBufferSize to that value.
  3. Deliver an oversized frame A subsequent decoded frame has info.size far larger than the locked buffer capacity.
  4. Overflow the recycled buffer obtainOutput returns a recycled undersized SampleBuffer, and writing info.size bytes overruns the SharedMemory allocation, corrupting memory.

Impact Assessment

An attacker gains a heap/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

FunctionChangeNotes
if
mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
modified
if
mobile/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.java
  • mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java

Audit Directions

  • Unreliable API return values as invariants
    audit 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 flags
    flag boolean-gated one-time sizing (like mHasOutputCapacitySet) for pooled or shared buffers where inputs can later exceed the first observed size.
  • Recycled buffer reuse without capacity checks
    review object/buffer pools that hand back recycled entries without verifying the buffer is large enough for the current request before writing into it.
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) {
Loading diff…