Medium watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Codecs
DescriptionUse after free in Codecs
ComponentCodecs
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker495996858
Fix commita047955845e5 (aom) +58/-20
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSyn4pse
Reported2026-03-25 (per vendor advisory)
Fix landed2026-03-30 (public git)
Disclosed2026-04-15
Patch gap16 days — fix public in source before the advisory

Background

`skip_inter_mode_nonrd`
the libaom AV1 non-RD real-time encoder routine that decides whether to skip evaluating a given inter prediction mode and may return true (skip) at several early points.
`force_mv_inter_layer`
an output flag (set via *force_mv_inter_layer) that tells the SVC (scalable video coding) path to reuse an inter-layer motion vector and skip the normal motion/scaled-reference search.
`av1_scale_references` / `av1_get_scaled_ref_frame`
the step that creates a scaled copy of a reference frame before encoding, and the accessor used later to fetch it; a scaled reference is only valid if it was actually built.
`combined_motion_search`
the motion-search routine (named in the commit message) that consumes the (possibly scaled) reference frame; the patch also adds asserts there that the scaled reference matches the current frame dimensions.

Root Cause Analysis

In skip_inter_mode_nonrd the block that sets *force_mv_inter_layer = 0; (and then conditionally to 1 for SVC spatial layers above the base) originally sat near the end of the function, after several return true early exits. On those early-skip paths the flag was therefore left at its stale/prior value instead of being deterministically initialized, so callers could proceed down the SVC inter-layer path believing a scaled reference was available. As the commit explains, this allowed use of a scaled_ref in pickmode’s combined motion search when it had “actually not been set/scaled in av1_scale_references,” i.e. the encoder dereferenced a scaled-reference buffer that was never built, producing a use-after-free.

The fix moves the *force_mv_inter_layer = 0; initialization and the SVC block to run before those early returns, guaranteeing the flag is always set, and adds asserts in the motion-search function verifying av1_get_scaled_ref_frame returned a non-NULL buffer whose crop dimensions equal cm->width/cm->height. This restores the invariant that the inter-layer/scaled-reference path is only taken when a valid scaled reference actually exists.

Key insight
The single mistake was initializing and setting force_mv_inter_layer too late — after skip_inter_mode_nonrd’s early return true paths — so a stale flag could steer the encoder into using a scaled reference frame that was never allocated; relocating the assignment earlier makes the flag deterministic on every exit.

Attack Path

  1. Craft an SVC encode configuration Drive the AV1 real-time encoder with scalable video coding enabled (use_svc, spatial_layer_id > 0) and a reference setup that disables LAST, as the added BasicRateTargetingSVC1TL2SLDisableLAST test does via disable_last_ref.
  2. Hit an early skip exit Produce block/mode conditions that cause skip_inter_mode_nonrd to return true before reaching the (old) location of the force_mv_inter_layer assignment, leaving the flag unset.
  3. Enter the scaled-reference path with no scaled frame With the stale flag, the encoder proceeds into combined_motion_search and calls av1_get_scaled_ref_frame for a reference that was never built by av1_scale_references.
  4. Use-after-free on the scaled reference The motion search reads the missing/freed scaled-reference buffer, the use-after-free the commit reports; the diff adds asserts here but shows no further primitive, so downstream escalation is (inferred).

Impact Assessment

An attacker who controls the encoder configuration or input stream for an SVC real-time encode can cause a use-after-free of a reference-frame buffer inside the AV1 encoder (libaom, used by Chrome’s codecs component). The precondition is exercising the SVC spatial-layer skip path such that force_mv_inter_layer is left stale and a scaled reference is consumed without being scaled. The advisory rates this medium severity and reports no in-the-wild exploitation; any escalation beyond the crash is (inferred).

Changed Functions

FunctionChangeNotes
if
av1/encoder/nonrd_pickmode.c
modified
if
test/svc_datarate_test.cc
modified
TEST_P
test/svc_datarate_test.cc
modified

Files Changed

  • av1/encoder/nonrd_pickmode.c
  • test/svc_datarate_test.cc

Audit Directions

  • Output flags set after early returns
    Audit functions with multiple return points that assign caller-visible out-parameters near the end; ensure every exit initializes them, or move initialization to the top.
  • Lazily-built resources consumed without a validity check
    Review paths that fetch scaled/derived buffers (e.g. av1_get_scaled_ref_frame) and confirm the producer (av1_scale_references) ran for that reference before use.
  • SVC spatial-layer branches
    Scrutinize use_svc / spatial_layer_id > 0 code paths in nonrd_pickmode.c, which depend on cross-layer state that normal single-layer tests do not exercise.
From a047955845e50e43786d51cdefcfc9e87804ed61 Mon Sep 17 00:00:00 2001
From: Marco Paniconi <marpan@google.com>
Date: Sun, 29 Mar 2026 20:27:20 -0700
Subject: [PATCH] Set force_mv_inter_layer earlier in skip_inter_mode

For nonrd_pickmode: move the setting of
force_mv_inter_layer earlier in the
skip_inter_mode_nonrd(), to make sure it always
get set (in case of false return in that function).

Thie prevents the usage of a scaled_ref in pickmode
(combined_motion search) when it has actually not been
set/scaled in av1_scale_references (before encoding).

Fixes a crash for use after free (UAF), reported
in the issues below.

Added svc unittest to generate the issue. Also added
assert check for scaled_ref in combined_motion_search.

Bug: 495477995, 495996858
Change-Id: I578d19156d97a50546edc9422bc3581566f1236e
---

diff --git a/av1/encoder/nonrd_pickmode.c b/av1/encoder/nonrd_pickmode.c
index 0f2a1c7..942b8ab 100644
--- a/av1/encoder/nonrd_pickmode.c
+++ b/av1/encoder/nonrd_pickmode.c
@@ -192,7 +192,7 @@
                                   int *rate_mv, int64_t best_rd_sofar,
                                   int use_base_mv) {
   MACROBLOCKD *xd = &x->e_mbd;
-  const AV1_COMMON *cm = &cpi->common;
+  AV1_COMMON *cm = &cpi->common;
   const SPEED_FEATURES *sf = &cpi->sf;
   MB_MODE_INFO *mi = xd->mi[0];
   int step_param = (sf->rt_sf.fullpel_search_step_param)
@@ -207,6 +207,14 @@
   int cost_list[5];
   int search_subpel = 1;
 
+  if (av1_is_scaled(get_ref_scale_factors(cm, ref))) {
+    const YV12_BUFFER_CONFIG *scaled_ref = av1_get_scaled_ref_frame(cpi, ref);
+    (void)scaled_ref;
+    assert(scaled_ref != NULL);
+    assert(scaled_ref->y_crop_width == cm->width &&
+           scaled_ref->y_crop_height == cm->height);
+  }
+
   start_mv = get_fullmv_from_mv(&ref_mv);
 
   if (!use_base_mv)
@@ -2490,6 +2498,23 @@
       (*this_mode != GLOBALMV || *ref_frame != LAST_FRAME))
     return true;
 
+  *force_mv_inter_layer = 0;
+  if (cpi->ppi->use_svc && svc->spatial_layer_id > 0 &&
+      ((*ref_frame == LAST_FRAME && svc->skip_mvsearch_last) ||
+       (*ref_frame == GOLDEN_FRAME && svc->skip_mvsearch_gf) ||
+       (*ref_frame == ALTREF_FRAME && svc->skip_mvsearch_altref))) {
+    // Only test mode if NEARESTMV/NEARMV is (svc_mv.mv.col, svc_mv.mv.row),
+    // otherwise set NEWMV to (svc_mv.mv.col, svc_mv.mv.row).
+    // Skip newmv and filter search.
+    *force_mv_inter_layer = 1;
+    if (*this_mode == NEWMV) {
+      search_state->frame_mv[*this_mode][*ref_frame] = svc_mv;
+    } else if (search_state->frame_mv[*this_mode][*ref_frame].as_int !=
+               svc_mv.as_int) {
+      return true;
+    }
+  }
+
   // If the segment reference frame feature is enabled then do nothing if the
   // current ref frame is not allowed.
   if (segfeature_active(seg, segment_id, SEG_LVL_REF_FRAME)) {
@@ -2565,23 +2590,6 @@
     return true;
   }
 
-  *force_mv_inter_layer = 0;
-  if (cpi->ppi->use_svc && svc->spatial_layer_id > 0 &&
-      ((*ref_frame == LAST_FRAME && svc->skip_mvsearch_last) ||
-       (*ref_frame == GOLDEN_FRAME && svc->skip_mvsearch_gf) ||
-       (*ref_frame == ALTREF_FRAME && svc->skip_mvsearch_altref))) {
-    // Only test mode if NEARESTMV/NEARMV is (svc_mv.mv.col, svc_mv.mv.row),
-    // otherwise set NEWMV to (svc_mv.mv.col, svc_mv.mv.row).
-    // Skip newmv and filter search.
-    *force_mv_inter_layer = 1;
-    if (*this_mode == NEWMV) {
-      search_state->frame_mv[*this_mode][*ref_frame] = svc_mv;
-    } else if (search_state->frame_mv[*this_mode][*ref_frame].as_int !=
-               svc_mv.as_int) {
-      return true;
-    }
-  }
-
   // For screen content: skip mode testing based on source_sad.
   if (cpi->oxcf.tune_cfg.content == AOM_CONTENT_SCREEN &&
       !x->force_zeromv_skip_for_blk) {
diff --git a/test/svc_datarate_test.cc b/test/svc_datarate_test.cc
index 0df6782..2f68ba7 100644
--- a/test/svc_datarate_test.cc
+++ b/test/svc_datarate_test.cc
@@ -247,6 +247,7 @@
     external_resize_pattern_ = 0;
     dynamic_tl_ = false;
     dynamic_scale_factors_ = false;
+    disable_last_ref_ = false;
   }
 
   void PreEncodeFrameHook(::libaom_test::VideoSource *video,
@@ -302,7 +303,7 @@
         spatial_layer_id, multi_ref_, comp_pred_,
         (video->frame() % cfg_.kf_max_dist) == 0, dynamic_enable_disable_mode_,
         rps_mode_, rps_recovery_frame_, simulcast_mode_, use_last_as_scaled_,
-        use_last_as_scaled_single_ref_);
+        use_last_as_scaled_single_ref_, disable_last_ref_);
     if (intra_only_ == 1 && frame_sync_ > 0) {
       // Set an Intra-only frame on SL0 at frame_sync_.
       // In order to allow decoding to start on SL0 in mid-sequence we need to
@@ -964,7 +965,7 @@
       int multi_ref, int comp_pred, int is_key_frame,
       int dynamic_enable_disable_mode, int rps_mode, int rps_recovery_frame,
       int simulcast_mode, bool use_last_as_scaled,
-      bool use_last_as_scaled_single_ref) {
+      bool use_last_as_scaled_single_ref, bool disable_last_ref) {
     int lag_index = 0;
     int base_count = frame_cnt >> 2;
     layer_id->spatial_layer_id = spatial_layer;
@@ -1164,6 +1165,11 @@
     if (dynamic_enable_disable_mode == 1 &&
         layer_id->spatial_layer_id == number_spatial_layers_ - 1)
       ref_frame_config->reference[0] = 0;
+    // Always disable LAST reference under this flag. use GOLDEN reference.
+    if (disable_last_ref) {
+      ref_frame_config->reference[0] = 0;
+      ref_frame_config->reference[3] = 1;
+    }
     return layer_flags;
   }
 
@@ -1508,6 +1514,23 @@
     CheckDatarate(0.80, 1.60);
   }
 
+  virtual void BasicRateTargetingSVC1TL2SLDisableLASTTest() {
+    SetUpCbr();
+    cfg_.g_error_resilient = 0;
+
+    ::libaom_test::I420VideoSource video("hantro_collage_w352h288.yuv", 352,
+                                         288, 30, 1, 0, 300);
+    const int bitrate_array[2] = { 300, 600 };
+    cfg_.rc_target_bitrate = bitrate_array[GET_PARAM(4)];
+    ResetModel();
+    disable_last_ref_ = true;
+    screen_mode_ = true;
+    ASSERT_NO_FATAL_FAILURE(RunLoop(&video));
+#if CONFIG_AV1_DECODER
+    EXPECT_EQ((int)GetMismatchFrames(), 0);
+#endif
+  }
+
   virtual void BasicRateTargetingSVC3TL3SLIntraStartDecodeBaseMidSeq() {
     SetUpCbr();
     cfg_.rc_max_quantizer = 56;
@@ -2380,6 +2403,7 @@
   int external_resize_pattern_;
   bool dynamic_tl_;
   bool dynamic_scale_factors_;
+  bool disable_last_ref_;
 };
 
 // Check basic rate targeting for CBR, for 3 temporal layers, 1 spatial.
@@ -2458,6 +2482,12 @@
   BasicRateTargetingSVC1TL2SLTest();
 }
 
+// Check basic rate targeting for CBR, for 2 spatial layers, 1 temporal.
+// Disable the usage of LAST referenc frame.
+TEST_P(DatarateTestSVC, BasicRateTargetingSVC1TL2SLDisableLAST) {
+  BasicRateTargetingSVC1TL2SLDisableLASTTest();
+}
+
 // Check basic rate targeting for CBR, for 3 spatial layers, 3 temporal,
 // with Intra-only frame inserted in the stream. Verify that we can start
 // decoding the SL0 stream at the intra_only frame in mid-sequence.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/svc_datarate_test.cc b/test/svc_datarate_test.cc
index 0df6782..2f68ba7 100644
--- a/test/svc_datarate_test.cc
+++ b/test/svc_datarate_test.cc
@@ -247,6 +247,7 @@
     external_resize_pattern_ = 0;
     dynamic_tl_ = false;
     dynamic_scale_factors_ = false;
+    disable_last_ref_ = false;
   }
 
   void PreEncodeFrameHook(::libaom_test::VideoSource *video,
@@ -302,7 +303,7 @@
         spatial_layer_id, multi_ref_, comp_pred_,
         (video->frame() % cfg_.kf_max_dist) == 0, dynamic_enable_disable_mode_,
         rps_mode_, rps_recovery_frame_, simulcast_mode_, use_last_as_scaled_,
-        use_last_as_scaled_single_ref_);
+        use_last_as_scaled_single_ref_, disable_last_ref_);
     if (intra_only_ == 1 && frame_sync_ > 0) {
       // Set an Intra-only frame on SL0 at frame_sync_.
       // In order to allow decoding to start on SL0 in mid-sequence we need to
@@ -964,7 +965,7 @@
       int multi_ref, int comp_pred, int is_key_frame,
       int dynamic_enable_disable_mode, int rps_mode, int rps_recovery_frame,
       int simulcast_mode, bool use_last_as_scaled,
-      bool use_last_as_scaled_single_ref) {
+      bool use_last_as_scaled_single_ref, bool disable_last_ref) {
     int lag_index = 0;
     int base_count = frame_cnt >> 2;
     layer_id->spatial_layer_id = spatial_layer;
@@ -1164,6 +1165,11 @@
     if (dynamic_enable_disable_mode == 1 &&
         layer_id->spatial_layer_id == number_spatial_layers_ - 1)
       ref_frame_config->reference[0] = 0;
+    // Always disable LAST reference under this flag. use GOLDEN reference.
+    if (disable_last_ref) {
+      ref_frame_config->reference[0] = 0;
+      ref_frame_config->reference[3] = 1;
+    }
     return layer_flags;
   }
 
@@ -1508,6 +1514,23 @@
     CheckDatarate(0.80, 1.60);
   }
 
+  virtual void BasicRateTargetingSVC1TL2SLDisableLASTTest() {
+    SetUpCbr();
+    cfg_.g_error_resilient = 0;
+
+    ::libaom_test::I420VideoSource video("hantro_collage_w352h288.yuv", 352,
+                                         288, 30, 1, 0, 300);
+    const int bitrate_array[2] = { 300, 600 };
+    cfg_.rc_target_bitrate = bitrate_array[GET_PARAM(4)];
+    ResetModel();
+    disable_last_ref_ = true;
+    screen_mode_ = true;
+    ASSERT_NO_FATAL_FAILURE(RunLoop(&video));
+#if CONFIG_AV1_DECODER
+    EXPECT_EQ((int)GetMismatchFrames(), 0);
+#endif
+  }
+
   virtual void BasicRateTargetingSVC3TL3SLIntraStartDecodeBaseMidSeq() {
     SetUpCbr();
     cfg_.rc_max_quantizer = 56;
@@ -2380,6 +2403,7 @@
   int external_resize_pattern_;
   bool dynamic_tl_;
   bool dynamic_scale_factors_;
+  bool disable_last_ref_;
 };
 
 // Check basic rate targeting for CBR, for 3 temporal layers, 1 spatial.
@@ -2458,6 +2482,12 @@
   BasicRateTargetingSVC1TL2SLTest();
 }
 
+// Check basic rate targeting for CBR, for 2 spatial layers, 1 temporal.
+// Disable the usage of LAST referenc frame.
+TEST_P(DatarateTestSVC, BasicRateTargetingSVC1TL2SLDisableLAST) {
+  BasicRateTargetingSVC1TL2SLDisableLASTTest();
+}
+
 // Check basic rate targeting for CBR, for 3 spatial layers, 3 temporal,
 // with Intra-only frame inserted in the stream. Verify that we can start
 // decoding the SL0 stream at the intra_only frame in mid-sequence.
Loading diff…

Original Bug Report

reported by he...@gmail.com

UAF in WebRTC LibaomAv1Encoder sync issues triggered by frequent resolution changes

Summary

VideoFramePool::PoolImpl::CreateFrame drops non-matching pooled backing allocations while AV1 encoder row-worker threads still hold raw pointers into the freed memory, leading to the UAF in webrtc.

Details

When the WebRTC encoder needs a mapped I420 buffer, the call chain ConvertToWebRtcVideoFrameBuffer → WebRtcVideoFrameAdapter::AdaptBestFrame → SharedResources::CreateFrame delegates to VideoFramePool::PoolImpl::CreateFrame for allocation. SharedResources::CreateFrame is a direct passthrough:

scoped_refptr<media::VideoFrame>
WebRtcVideoFrameAdapter::SharedResources::CreateFrame(
    media::VideoPixelFormat format,
    const gfx::Size& coded_size,
    const gfx::Rect& visible_rect,
    const gfx::Size& natural_size,
    base::TimeDelta timestamp) {
  return pool_.CreateFrame(format, coded_size, visible_rect, natural_size,
                           timestamp);
}

Inside VideoFramePool::PoolImpl::CreateFrame, the pool iterates over cached frames looking for a size/format match. Non-matching frames are popped and their backing memory is freed immediately when the scoped_refptr goes out of scope:

while (!frames_.empty()) {
  scoped_refptr<VideoFrame> pool_frame = std::move(frames_.back().frame);
  frames_.pop_back();

  if (pool_frame->IsSameAllocation(format, coded_size, visible_rect,
                                   natural_size)) {
    frame = pool_frame;
    frame->set_timestamp(timestamp);
    frame->clear_metadata();
    break;
  }
}

When a matching frame is found (or a new one allocated), it is returned wrapped. The wrapper’s destruction observer reinserts the backing frame into the pool:

scoped_refptr<VideoFrame> wrapped_frame = VideoFrame::WrapVideoFrame(
    frame, frame->format(), frame->visible_rect(), frame->natural_size());
wrapped_frame->AddDestructionObserver(base::BindOnce(
    &VideoFramePool::PoolImpl::FrameReleased, this, std::move(frame)));
return wrapped_frame;

The converted frame is surfaced to the encoder through I420FrameAdapter, whose accessors return raw pointers into the underlying media::VideoFrame data:

const uint8_t* DataY() const override {
  return frame_->visible_data(media::VideoFrame::Plane::kY);
}

The AV1 encoder (LibaomAv1Encoder::Encode) calls GetMappedFrameBuffer, which triggers AdaptBestFrame → ConvertToWebRtcVideoFrameBuffer → CreateFrame.

This invokes VideoFramePool::PoolImpl::CreateFrame on the encoder thread. If a previous frame’s wrapper has already been returned to the pool, and the new CreateFrame call encounters a size change, it drops that pooled backing. However, the AV1 encoder dispatches encoding work to CodecWorker threads via enc_row_mt_worker_hook, and those workers read frame plane data through the raw pointers obtained from I420FrameAdapter. There is no synchronization that prevents a subsequent CreateFrame call from reclaiming the backing while row workers from a prior encode are still reading from those pointers, leading to the UAF.

Reproduction

Run chromium (e.g., https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1603396.zip) with:

./chrome --no-sandbox poc.html

You would observe the stack-use-after-return crash shown in asan.txt

View on issue tracker