Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Compositing
DescriptionUse after free in Compositing
ComponentCompositing
Sub-componentViz
Bug ClassUAF
Tracker563163857
Fix commit1751df7b2ddd (chromium/src) +175/-6
CISA KEVNot listed
CreditedShubham Raj, Causal Security - https://causalsecurity.com
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
TEST_F
components/viz/service/hit_test/hit_test_aggregator_unittest.cc
modified

Files Changed

  • components/viz/common/hit_test/hit_test_region_list.h
  • components/viz/service/hit_test/hit_test_aggregator_unittest.cc
  • components/viz/service/hit_test/hit_test_aggregator.cc
From 1751df7b2ddd9f336aa398eae9e0850718449f19 Mon Sep 17 00:00:00 2001
From: Victor Miura <vmiura@chromium.org>
Date: Mon, 21 Sep 2026 07:02:07 -0700
Subject: [PATCH] viz: Apply hit-test list transform only to aggregation root

Fixed: 563163857
Change-Id: Id58bce3614343f996cea3403cb426389030b7f76
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8431894
Reviewed-by: Jonathan Ross <jonross@chromium.org>
Commit-Queue: Jonathan Ross <jonross@chromium.org>
Auto-Submit: Victor Miura <vmiura@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1701726}
---
diff --git a/components/viz/common/hit_test/hit_test_region_list.h b/components/viz/common/hit_test/hit_test_region_list.h
index de1a7e1..854d978bd 100644
--- a/components/viz/common/hit_test/hit_test_region_list.h
+++ b/components/viz/common/hit_test/hit_test_region_list.h
@@ -96,7 +96,11 @@
   // The bounds of the surface.
   gfx::Rect bounds;
 
-  // The transform applied to all regions in this surface.
+  // The transform applied to all regions in this surface. Only used for the
+  // surface that hit-test aggregation starts from, e.g. the external draw
+  // transform used by Android WebView. For an embedded surface the mapping
+  // into the surface is defined by the embedder's HitTestRegion, and this
+  // transform is ignored.
   gfx::Transform transform;
 
   // The list of sub-regions in front to back order.
diff --git a/components/viz/service/hit_test/hit_test_aggregator.cc b/components/viz/service/hit_test/hit_test_aggregator.cc
index 280b0ac..c41b663 100644
--- a/components/viz/service/hit_test/hit_test_aggregator.cc
+++ b/components/viz/service/hit_test/hit_test_aggregator.cc
@@ -185,11 +185,12 @@
       reasons |= AsyncHitTestReasons::kRegionNotActive;
     } else {
       // Rather than add a node in the tree for this hit_test_region_list
-      // element we can simplify the tree by merging the flags and transform
-      // into the kHitTestChildSurface element.
-      if (!hit_test_region_list->transform.IsIdentity())
-        transform.PreConcat(hit_test_region_list->transform);
-
+      // element we can simplify the tree by merging the flags into the
+      // kHitTestChildSurface element. The list-level transform is not
+      // merged: the mapping from the embedder's space into the embedded
+      // surface is defined by the embedder-submitted `region`, and a
+      // list-level transform is only applied for the surface that
+      // aggregation starts from (see AppendRoot()).
       flags |= hit_test_region_list->flags;
       reasons |= hit_test_region_list->async_hit_test_reasons;
 
diff --git a/components/viz/service/hit_test/hit_test_aggregator_unittest.cc b/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
index f9328a8..f8bfc99 100644
--- a/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
+++ b/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
@@ -11,6 +11,7 @@
 #include <utility>
 
 #include "base/memory/raw_ptr.h"
+#include "components/viz/common/hit_test/hit_test_query.h"
 #include "components/viz/common/hit_test/hit_test_region_list.h"
 #include "components/viz/common/surfaces/frame_sink_id.h"
 #include "components/viz/common/surfaces/surface_id.h"
@@ -1282,6 +1283,169 @@
   EXPECT_EQ(sibling_id, post_aggregation_list->regions[0].frame_sink_id);
 }
 
+// One embedder with two embedded surfaces, where c1 submits a
+// HitTestRegionList with a non-identity list-level transform. The mapping
+// from the embedder's space into an embedded surface is defined by the
+// embedder's HitTestRegion, so the embedded surface's list-level transform
+// must not be folded into its aggregated region: events over c2 and over the
+// embedder keep routing there.
+//
+//  +e-------------+
+//  | +c1-+        |
+//  | +---+        |
+//  |       +c2--+ |
+//  |       +----+ |
+//  +--------------+
+//
+TEST_F(HitTestAggregatorTest, EmbeddedSurfaceListTransformIgnored) {
+  TestHitTestAggregator* aggregator = hit_test_aggregator();
+  EXPECT_EQ(aggregator->GetRegionCount(), 0);
+
+  SurfaceId e_surface_id = MakeSurfaceId(kDisplayClientId);
+  SurfaceId c1_surface_id = MakeSurfaceId(kDisplayClientId + 1);
+  SurfaceId c2_surface_id = MakeSurfaceId(kDisplayClientId + 2);
+
+  frame_sink_manager()->RegisterFrameSinkHierarchy(
+      e_surface_id.frame_sink_id(), c1_surface_id.frame_sink_id());
+  frame_sink_manager()->RegisterFrameSinkHierarchy(
+      e_surface_id.frame_sink_id(), c2_surface_id.frame_sink_id());
+
+  HitTestRegionList e_hit_test_region_list;
+  e_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                 HitTestRegionFlags::kHitTestMouse |
+                                 HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_list.bounds.SetRect(0, 0, 1024, 768);
+
+  HitTestRegion e_hit_test_region_c1;
+  e_hit_test_region_c1.flags = HitTestRegionFlags::kHitTestChildSurface |
+                               HitTestRegionFlags::kHitTestMouse |
+                               HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_c1.frame_sink_id = c1_surface_id.frame_sink_id();
+  e_hit_test_region_c1.rect = gfx::RRectF(gfx::RectF(0, 0, 333, 222));
+
+  HitTestRegion e_hit_test_region_c2;
+  e_hit_test_region_c2.flags = HitTestRegionFlags::kHitTestChildSurface |
+                               HitTestRegionFlags::kHitTestMouse |
+                               HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_c2.frame_sink_id = c2_surface_id.frame_sink_id();
+  e_hit_test_region_c2.rect = gfx::RRectF(gfx::RectF(0, 0, 300, 200));
+  e_hit_test_region_c2.transform.Translate(-500, -400);
+
+  e_hit_test_region_list.regions.push_back(std::move(e_hit_test_region_c1));
+  e_hit_test_region_list.regions.push_back(std::move(e_hit_test_region_c2));
+
+  // c1 claims a list-level transform that collapses any point in the
+  // embedder onto its own region.
+  HitTestRegionList c1_hit_test_region_list;
+  c1_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                  HitTestRegionFlags::kHitTestMouse |
+                                  HitTestRegionFlags::kHitTestTouch;
+  c1_hit_test_region_list.bounds.SetRect(0, 0, 333, 222);
+  c1_hit_test_region_list.transform.Scale(0.000001, 0.000001);
+
+  HitTestRegionList c2_hit_test_region_list;
+  c2_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                  HitTestRegionFlags::kHitTestMouse |
+                                  HitTestRegionFlags::kHitTestTouch;
+  c2_hit_test_region_list.bounds.SetRect(0, 0, 300, 200);
+
+  auto support2 = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, frame_sink_manager(), c1_surface_id.frame_sink_id(),
+      false /* is_root */);
+  support2->SubmitCompositorFrame(c1_surface_id.local_surface_id(),
+                                  MakeDefaultCompositorFrame(),
+                                  std::move(c1_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(c1_surface_id);
+  auto support3 = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, frame_sink_manager(), c2_surface_id.frame_sink_id(),
+      false /* is_root */);
+  support3->SubmitCompositorFrame(c2_surface_id.local_surface_id(),
+                                  MakeDefaultCompositorFrame(),
+                                  std::move(c2_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(c2_surface_id);
+  support()->SubmitCompositorFrame(e_surface_id.local_surface_id(),
+                                   MakeDefaultCompositorFrame(),
+                                   std::move(e_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(e_surface_id);
+
+  aggregator->Aggregate(e_surface_id);
+
+  EXPECT_EQ(aggregator->GetRegionCount(), 3);
+
+  AggregatedHitTestRegion region = host_regions()[0];
+  EXPECT_EQ(region.frame_sink_id, e_surface_id.frame_sink_id());
+  EXPECT_EQ(region.child_count, 2);
+
+  // The aggregated region for c1 keeps the transform its embedder authored.
+  region = host_regions()[1];
+  EXPECT_EQ(HitTestRegionFlags::kHitTestChildSurface |
+                HitTestRegionFlags::kHitTestMine |
+                HitTestRegionFlags::kHitTestMouse |
+                HitTestRegionFlags::kHitTestTouch,
+            region.flags);
+  EXPECT_EQ(region.frame_sink_id, c1_surface_id.frame_sink_id());
+  EXPECT_EQ(region.rect, gfx::RRectF(gfx::RectF(0, 0, 333, 222)));
+  EXPECT_EQ(gfx::Transform(), region.transform);
+
+  region = host_regions()[2];
+  EXPECT_EQ(region.frame_sink_id, c2_surface_id.frame_sink_id());
+  gfx::Transform expected_c2_transform;
+  expected_c2_transform.Translate(-500, -400);
+  EXPECT_EQ(expected_c2_transform, region.transform);
+
+  // Targeting is unaffected by the list-level transform c1 submitted.
+  HitTestQuery query{std::nullopt};
+  query.OnAggregatedHitTestRegionListUpdated(host_regions());
+
+  // A point over c2 routes to c2, in c2's local coordinates.
+  Target target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(650, 500));
+  EXPECT_EQ(target.frame_sink_id, c2_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(150, 100));
+
+  // A point over c1 routes to c1.
+  target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(100, 50));
+  EXPECT_EQ(target.frame_sink_id, c1_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(100, 50));
+
+  // A point over neither embedded surface routes to the embedder.
+  target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(900, 700));
+  EXPECT_EQ(target.frame_sink_id, e_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(900, 700));
+}
+
+// The list-level transform of the surface that aggregation starts from is
+// applied to the root region, e.g. the external draw transform used by
+// Android WebView.
+TEST_F(HitTestAggregatorTest, RootSurfaceListTransformApplied) {
+  TestHitTestAggregator* aggregator = hit_test_aggregator();
+  EXPECT_EQ(aggregator->GetRegionCount(), 0);
+
+  SurfaceId display_surface_id = MakeSurfaceId(kDisplayClientId);
+
+  HitTestRegionList hit_test_region_list;
+  hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine;
+  hit_test_region_list.bounds.SetRect(0, 0, 1024, 768);
+  hit_test_region_list.transform.Translate(-10, -20);
+
+  support()->SubmitCompositorFrame(display_surface_id.local_surface_id(),
+                                   MakeDefaultCompositorFrame(),
+                                   std::move(hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(display_surface_id);
+  aggregator->Aggregate(display_surface_id);
+
+  EXPECT_EQ(aggregator->GetRegionCount(), 1);
+
+  AggregatedHitTestRegion region = host_regions()[0];
+  EXPECT_EQ(region.frame_sink_id, display_surface_id.frame_sink_id());
+  EXPECT_EQ(region.rect, gfx::RRectF(gfx::RectF(0, 0, 1024, 768)));
+  gfx::Transform expected_transform;
+  expected_transform.Translate(-10, -20);
+  EXPECT_EQ(expected_transform, region.transform);
+}
+
 TEST_F(HitTestAggregatorTest, RoundedCornersPropagate) {
   TestHitTestAggregator* aggregator = hit_test_aggregator();
   EXPECT_EQ(aggregator->GetRegionCount(), 0);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/viz/service/hit_test/hit_test_aggregator_unittest.cc b/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
index f9328a8..f8bfc99 100644
--- a/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
+++ b/components/viz/service/hit_test/hit_test_aggregator_unittest.cc
@@ -11,6 +11,7 @@
 #include <utility>
 
 #include "base/memory/raw_ptr.h"
+#include "components/viz/common/hit_test/hit_test_query.h"
 #include "components/viz/common/hit_test/hit_test_region_list.h"
 #include "components/viz/common/surfaces/frame_sink_id.h"
 #include "components/viz/common/surfaces/surface_id.h"
@@ -1282,6 +1283,169 @@
   EXPECT_EQ(sibling_id, post_aggregation_list->regions[0].frame_sink_id);
 }
 
+// One embedder with two embedded surfaces, where c1 submits a
+// HitTestRegionList with a non-identity list-level transform. The mapping
+// from the embedder's space into an embedded surface is defined by the
+// embedder's HitTestRegion, so the embedded surface's list-level transform
+// must not be folded into its aggregated region: events over c2 and over the
+// embedder keep routing there.
+//
+//  +e-------------+
+//  | +c1-+        |
+//  | +---+        |
+//  |       +c2--+ |
+//  |       +----+ |
+//  +--------------+
+//
+TEST_F(HitTestAggregatorTest, EmbeddedSurfaceListTransformIgnored) {
+  TestHitTestAggregator* aggregator = hit_test_aggregator();
+  EXPECT_EQ(aggregator->GetRegionCount(), 0);
+
+  SurfaceId e_surface_id = MakeSurfaceId(kDisplayClientId);
+  SurfaceId c1_surface_id = MakeSurfaceId(kDisplayClientId + 1);
+  SurfaceId c2_surface_id = MakeSurfaceId(kDisplayClientId + 2);
+
+  frame_sink_manager()->RegisterFrameSinkHierarchy(
+      e_surface_id.frame_sink_id(), c1_surface_id.frame_sink_id());
+  frame_sink_manager()->RegisterFrameSinkHierarchy(
+      e_surface_id.frame_sink_id(), c2_surface_id.frame_sink_id());
+
+  HitTestRegionList e_hit_test_region_list;
+  e_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                 HitTestRegionFlags::kHitTestMouse |
+                                 HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_list.bounds.SetRect(0, 0, 1024, 768);
+
+  HitTestRegion e_hit_test_region_c1;
+  e_hit_test_region_c1.flags = HitTestRegionFlags::kHitTestChildSurface |
+                               HitTestRegionFlags::kHitTestMouse |
+                               HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_c1.frame_sink_id = c1_surface_id.frame_sink_id();
+  e_hit_test_region_c1.rect = gfx::RRectF(gfx::RectF(0, 0, 333, 222));
+
+  HitTestRegion e_hit_test_region_c2;
+  e_hit_test_region_c2.flags = HitTestRegionFlags::kHitTestChildSurface |
+                               HitTestRegionFlags::kHitTestMouse |
+                               HitTestRegionFlags::kHitTestTouch;
+  e_hit_test_region_c2.frame_sink_id = c2_surface_id.frame_sink_id();
+  e_hit_test_region_c2.rect = gfx::RRectF(gfx::RectF(0, 0, 300, 200));
+  e_hit_test_region_c2.transform.Translate(-500, -400);
+
+  e_hit_test_region_list.regions.push_back(std::move(e_hit_test_region_c1));
+  e_hit_test_region_list.regions.push_back(std::move(e_hit_test_region_c2));
+
+  // c1 claims a list-level transform that collapses any point in the
+  // embedder onto its own region.
+  HitTestRegionList c1_hit_test_region_list;
+  c1_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                  HitTestRegionFlags::kHitTestMouse |
+                                  HitTestRegionFlags::kHitTestTouch;
+  c1_hit_test_region_list.bounds.SetRect(0, 0, 333, 222);
+  c1_hit_test_region_list.transform.Scale(0.000001, 0.000001);
+
+  HitTestRegionList c2_hit_test_region_list;
+  c2_hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine |
+                                  HitTestRegionFlags::kHitTestMouse |
+                                  HitTestRegionFlags::kHitTestTouch;
+  c2_hit_test_region_list.bounds.SetRect(0, 0, 300, 200);
+
+  auto support2 = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, frame_sink_manager(), c1_surface_id.frame_sink_id(),
+      false /* is_root */);
+  support2->SubmitCompositorFrame(c1_surface_id.local_surface_id(),
+                                  MakeDefaultCompositorFrame(),
+                                  std::move(c1_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(c1_surface_id);
+  auto support3 = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, frame_sink_manager(), c2_surface_id.frame_sink_id(),
+      false /* is_root */);
+  support3->SubmitCompositorFrame(c2_surface_id.local_surface_id(),
+                                  MakeDefaultCompositorFrame(),
+                                  std::move(c2_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(c2_surface_id);
+  support()->SubmitCompositorFrame(e_surface_id.local_surface_id(),
+                                   MakeDefaultCompositorFrame(),
+                                   std::move(e_hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(e_surface_id);
+
+  aggregator->Aggregate(e_surface_id);
+
+  EXPECT_EQ(aggregator->GetRegionCount(), 3);
+
+  AggregatedHitTestRegion region = host_regions()[0];
+  EXPECT_EQ(region.frame_sink_id, e_surface_id.frame_sink_id());
+  EXPECT_EQ(region.child_count, 2);
+
+  // The aggregated region for c1 keeps the transform its embedder authored.
+  region = host_regions()[1];
+  EXPECT_EQ(HitTestRegionFlags::kHitTestChildSurface |
+                HitTestRegionFlags::kHitTestMine |
+                HitTestRegionFlags::kHitTestMouse |
+                HitTestRegionFlags::kHitTestTouch,
+            region.flags);
+  EXPECT_EQ(region.frame_sink_id, c1_surface_id.frame_sink_id());
+  EXPECT_EQ(region.rect, gfx::RRectF(gfx::RectF(0, 0, 333, 222)));
+  EXPECT_EQ(gfx::Transform(), region.transform);
+
+  region = host_regions()[2];
+  EXPECT_EQ(region.frame_sink_id, c2_surface_id.frame_sink_id());
+  gfx::Transform expected_c2_transform;
+  expected_c2_transform.Translate(-500, -400);
+  EXPECT_EQ(expected_c2_transform, region.transform);
+
+  // Targeting is unaffected by the list-level transform c1 submitted.
+  HitTestQuery query{std::nullopt};
+  query.OnAggregatedHitTestRegionListUpdated(host_regions());
+
+  // A point over c2 routes to c2, in c2's local coordinates.
+  Target target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(650, 500));
+  EXPECT_EQ(target.frame_sink_id, c2_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(150, 100));
+
+  // A point over c1 routes to c1.
+  target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(100, 50));
+  EXPECT_EQ(target.frame_sink_id, c1_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(100, 50));
+
+  // A point over neither embedded surface routes to the embedder.
+  target =
+      query.FindTargetForLocation(EventSource::MOUSE, gfx::PointF(900, 700));
+  EXPECT_EQ(target.frame_sink_id, e_surface_id.frame_sink_id());
+  EXPECT_EQ(target.location_in_target, gfx::PointF(900, 700));
+}
+
+// The list-level transform of the surface that aggregation starts from is
+// applied to the root region, e.g. the external draw transform used by
+// Android WebView.
+TEST_F(HitTestAggregatorTest, RootSurfaceListTransformApplied) {
+  TestHitTestAggregator* aggregator = hit_test_aggregator();
+  EXPECT_EQ(aggregator->GetRegionCount(), 0);
+
+  SurfaceId display_surface_id = MakeSurfaceId(kDisplayClientId);
+
+  HitTestRegionList hit_test_region_list;
+  hit_test_region_list.flags = HitTestRegionFlags::kHitTestMine;
+  hit_test_region_list.bounds.SetRect(0, 0, 1024, 768);
+  hit_test_region_list.transform.Translate(-10, -20);
+
+  support()->SubmitCompositorFrame(display_surface_id.local_surface_id(),
+                                   MakeDefaultCompositorFrame(),
+                                   std::move(hit_test_region_list));
+  local_surface_id_lookup_delegate()->SetSurfaceIdMap(display_surface_id);
+  aggregator->Aggregate(display_surface_id);
+
+  EXPECT_EQ(aggregator->GetRegionCount(), 1);
+
+  AggregatedHitTestRegion region = host_regions()[0];
+  EXPECT_EQ(region.frame_sink_id, display_surface_id.frame_sink_id());
+  EXPECT_EQ(region.rect, gfx::RRectF(gfx::RectF(0, 0, 1024, 768)));
+  gfx::Transform expected_transform;
+  expected_transform.Translate(-10, -20);
+  EXPECT_EQ(expected_transform, region.transform);
+}
+
 TEST_F(HitTestAggregatorTest, RoundedCornersPropagate) {
   TestHitTestAggregator* aggregator = hit_test_aggregator();
   EXPECT_EQ(aggregator->GetRegionCount(), 0);
Loading diff…

Original Bug Report

reported by xc...@gmail.com

A compromised renderer intercepts mouse and touch input aimed at its embedder and at cross-process sibling frames by choosing its own `HitTestRegionList::transform`


Report description

A compromised renderer intercepts mouse and touch input aimed at its embedder and at cross-process sibling frames by choosing its own HitTestRegionList::transform


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src


The problem

Please describe the technical details of the vulnerability

Title: A compromised renderer intercepts mouse and touch input aimed at its embedder and at cross-process sibling frames by choosing its own HitTestRegionList::transform

Attack: An attacker can cause Chromium to deliver a mouse or touch event whose true target is a cross-process frame to a different, compromised renderer instead, by setting the transform field on the HitTestRegionList its compositor submits.

Analyzed at: 02c73ef5489a46a07333f133770f3af12dc755de = refs/heads/main@{#1698616}, chrome/VERSION 156.0.8062.0. Re-read at main on 2026-09-17 and unchanged. Line numbers below are at main as of that date.

The one CL that targeted this mechanism was abandoned by its own author on 2026-09-17, a week after it passed CQ, and a sweep finds no successor. See Prior art.


content/SECURITY.md:87-88 lists this under High-Priority Vulnerabilities, Site Isolation Bypasses:

> * Input Interception: Allowing a compromised renderer to intercept input events intended for cross-origin or cross-process frames.

Attack steps

  1. The attacker has arbitrary code in one renderer, whose frame is an ordinary cross-origin iframe of a victim page with a registered frame-sink hierarchy.
  2. The renderer’s compositor submits a HitTestRegionList with a non-identity transform, for example Scale(1e-6, 1e-6). The CompositorFrameSink is renderer-owned (services/viz/public/mojom/compositing/compositor_frame_sink.mojom:88-92), so a compromised renderer emits the struct directly rather than going through HitTestDataBuilder.
  3. The victim moves the pointer or touches anywhere in the tab.
  4. The event is delivered to the attacker’s RenderWidgetHostViewInput.

The attacker does not need to know the embedder’s transform. The aggregated mapping is M(p) = E(C(p)) where E is embedder-authored and C attacker-authored. In production geometry the child’s region rect is its own bounds at the origin, so a near-zero scale collapses every point in the root viewport onto a point that rect contains by construction.

There is no web API that sets this field, so ordinary web content cannot reach it.

Root cause

The rect a point is tested against is authored by the embedder. The transform the point is mapped through includes a factor authored by the child.

components/viz/service/hit_test/hit_test_aggregator.cc:188-191:

      // element we can simplify the tree by merging the flags and transform
      // into the kHitTestChildSurface element.
      if (!hit_test_region_list->transform.IsIdentity())
        transform.PreConcat(hit_test_region_list->transform);

components/viz/common/hit_test/hit_test_query.cc:205-214 consumes it:

    location_transformed =
        GetHitTestRegionData()[region_index].transform.MapPoint(
            location_transformed);
    ...
    if (!GetHitTestRegionData()[region_index].rect.rect().Contains(
            location_transformed)) {
      return false;
    }

The child therefore chooses the coordinate space in which its own geometric allocation is evaluated. PreConcat is this = this * transform, so the attacker’s factor applies to the point first.

Nothing validates that factor on the way in:

  • services/viz/public/cpp/hit_test/hit_test_region_list_mojom_traits.cc:39-41 is a bare data.ReadTransform(&out->transform). The only numeric constraint on the wire, ui/gfx/mojom/transform_mojom_traits.h:62-66, rejects non-finite values on the kMatrix union branch only; the kAxis2d branch at :47-55, which is what Translate() and Scale() produce, has no numeric check.
  • components/viz/service/hit_test/hit_test_manager.cc:128-151 checks regions.size() against kMaxRegionsPerSurface, flag and async-reason coherence, and a client_id == 0 fixup. Its comment at :139-142 defers hierarchy checking to aggregation. Geometry is never examined.
  • hit_test_aggregator.cc:165-166 runs IsChildOf(submitting_frame_sink_id, region.frame_sink_id) under kRejectInvalidChildRegions, FEATURE_ENABLED_BY_DEFAULT at :21. That validates who the submitter is, not where it may claim to be, and the attacker names its own legitimately registered sink, so it passes.

For every non-WebView renderer the honestly-built list transform is always identity. HitTestDataBuilder::Build sets it from active_tree_->DrawTransform() (cc/trees/hit_test_data_builder.cc:132), which resolves to LayerTreeHostImpl::external_transform_. That member has one writer, LayerTreeHostImpl::OnDraw, whose comment at cc/trees/layer_tree_host_impl.cc:2281-2283 states it is only ever called by Android WebView. Attacker control comes from renderer ownership of the frame sink, not from cc computing a useful value.

This is a different field from the per-region transform set by PopulateHitTestRegion at hit_test_data_builder.cc:111-118, which is embedder-authored.

Reproduction

Attached: poc.html, attacker.html, victim.html, server.py, poc.patch, screenshot_attack.png, screenshot_control.png.

Why a patch rather than MojoJS

The attacker-controlled value is a field of the struct the renderer’s compositor passes to viz.mojom.CompositorFrameSink::SubmitCompositorFrame (services/viz/public/mojom/compositing/compositor_frame_sink.mojom:88-92). No web API sets it, and MojoJS cannot reach the interface. Measured at this revision with --enable-blink-features=MojoJS, one probe per renderer process, against a deliberately nonexistent interface as the control:

Mojo.bindInterface scope result
cipher.mojom.NoSuchInterface (control) frame/document renderer killed, no binder found
viz.mojom.CompositorFrameSink frame/document renderer killed, no binder found
content.mojom.FrameSinkProvider frame/document renderer killed, no binder found
cipher.mojom.NoSuchInterface (control) process pipe closed by peer
viz.mojom.CompositorFrameSink process pipe closed by peer, identical to the control
viz.mojom.FrameSinkManager process pipe closed by peer, identical to the control
blink.mojom.EmbeddedFrameSinkProvider process stayed connected

EmbeddedFrameSinkProvider is brokered but is not a route to this bug. RegisterEmbeddedFrameSink rejects any FrameSinkId whose client_id is not the calling renderer’s own (content/browser/renderer_host/embedded_frame_sink_provider_impl.cc:34-41), so a sink obtained through it cannot submit frames as another frame’s sink, and the surfaces it does serve are canvas surfaces rather than the OOPIF child surfaces that appear in the embedder’s hit-test region list.

poc.patch therefore emits the hostile value from the renderer compositor, which is where a compromised renderer would produce it. It is one file, 21 added lines, no logging, guarded on --type="renderer" and on a frame whose own device viewport is exactly 333x222 CSS px, which is the size poc.html gives the attacker iframe. Under ?control=1 the page sizes that iframe 334x222, which leaves the patched path inert.

Steps

Base commit 02c73ef5489a46a07333f133770f3af12dc755de (refs/heads/main@{#1698616}, chrome/VERSION 156.0.8062.0), Linux x86_64.

  1. git checkout 02c73ef5489a46a07333f133770f3af12dc755de
  2. git apply poc.patch, which touches only cc/trees/hit_test_data_builder.cc
  3. gn gen out/poc with is_debug=false dcheck_always_on=true is_component_build=true enable_nacl=false, then autoninja -C out/poc chrome
  4. python3 server.py from the directory holding the three pages, no arguments, not root
  5. Control arm: out/poc/chrome --user-data-dir=/tmp/pocprofile1 --window-size=1400,900 'http://localhost:8000/poc.html?control=1', then click the middle of the green victim box.
  6. Attack arm: the same command without ?control=1 and with a fresh --user-data-dir, then click in exactly the same place.

A fresh --user-data-dir and --window-size are the only flags. No feature flag, no MojoJS, no CDP, no devtools, and nothing to read out of a log.

The three origins are distinct loopback hostnames so that the embedder, the attacker frame and the victim frame land in three different renderer processes. The runs reported below were made in a component ASAN build, which is simply the tree that was already built; ASAN is incidental, this is not a memory-safety bug, and the plain configuration in step 3 was not itself run.

Result

Each frame reports in its own DOM whether it received the event. The click is nowhere near the attacker’s frame in either arm.

arm attacker frame, top left victim frame, middle embedder
control, ?control=1 received nothing received mousedown #1 at local (147,97) nothing
attack received mousedown #1 at local (0,0) nothing nothing

The only difference between the two arms is the attacker iframe’s width, 333 versus 334 CSS px, which decides whether the patched path emits the hostile transform. Same click coordinates, same binary, same page, same three processes.

screenshot_attack.png and screenshot_control.png are those two outcomes. Three attack runs produced byte-identical screenshots of one another and three control runs likewise, with the two classes differing.

The control also measures the fix shape: with the hostile transform absent the click routes correctly into the cross-process victim frame at that frame’s own local coordinates, so refusing a non-identity child list transform restores routing rather than denying input.

Not separately run: a mojom-traits round-trip test confirming the payload survives deserialization on the kAxis2d branch, which the analysis above predicts.

Suggested fix

Stop folding the child-submitted list transform into the embedder’s kHitTestChildSurface region at hit_test_aggregator.cc:190-191, or reject a non-identity value there.

This was applied to that file and measured. Refusing to fold a non-identity child transform restores routing into the victim’s own coordinate space rather than denying the event, and the upstream HitTestAggregatorTest.* and HitTestQueryTest.* corpus stays green, 18 tests and 0 failures. Reverting restores the bypass. The control arm of the attached reproduction shows the same restored routing in a real browser.

A production patch needs to carve out the Android WebView OnDraw path, which is the one caller that legitimately produces a non-identity value.

Fixing it at the producing layer is preferable to a browser-side compensating control, which is the approach the abandoned CL below took.

Credit

Cipher / Causal Security - https://causalsecurity.com/

Impact analysis

Security consequences

A compromised renderer receives mouse and touch events whose true target is its embedder or a cross-process sibling frame, with location_in_target computed through a transform it chose. The attacker is named as the target and the true target is not, so this is both interception and denial.

Because the attacker authors the transform it can invert it: a mild payload such as Scale(0.9,0.9) recovers root-space coordinates of pointer events over the victim’s frames along with event type and timing. Once it is the kMouseDown target, last_mouse_down_target_ is set to it (components/input/render_widget_host_input_event_router.cc:2067), so a subsequent SetMouseCaptureTarget (:2194) passes the check at :2202-2206, whose own comment says a frame should only be able to capture the mouse if it was the target of the last mouse down and that this “prevents malicious frames (e.g. OOPIFs or Fenced Frames) from hijacking mouse events intended for other frames”, and the attacker latches subsequent mouse events irrespective of hit testing.

Keyboard input is not affected. WebContentsImpl::GetFocusedRenderWidgetHost (web_contents_impl.cc:4874-4890) routes keyboard by the focused frame, a browser-maintained value, and focus changes derive the principal browser-side in RenderFrameHostImpl::DidFocusFrame. Nothing here redirects a keystroke.


The cause

What version of Chrome have you found the security issue in?

chrome 156.0.8062.0. 02c73ef5489a46a07333f133770f3af12dc755de, refs/heads/main@{#1698616}

No, it is not related to a crash.

Choose the type of vulnerability

Site Isolation Bypass

How would you like to be publicly acknowledged for your report?

Shubham Raj (Cipher by Causal Security) - https://causalsecurity.com/

View on issue tracker