CVE-2026-113111
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/viz/service/hit_test/hit_test_aggregator_unittest.cc |
modified |
Files Changed
components/viz/common/hit_test/hit_test_region_list.hcomponents/viz/service/hit_test/hit_test_aggregator_unittest.cccomponents/viz/service/hit_test/hit_test_aggregator.cc
Patch
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);
Regression Test / PoC
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);
Original Bug Report
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
- 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.
- The renderer’s compositor submits a
HitTestRegionListwith a non-identitytransform, for exampleScale(1e-6, 1e-6). TheCompositorFrameSinkis renderer-owned (services/viz/public/mojom/compositing/compositor_frame_sink.mojom:88-92), so a compromised renderer emits the struct directly rather than going throughHitTestDataBuilder. - The victim moves the pointer or touches anywhere in the tab.
- 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-41is a baredata.ReadTransform(&out->transform). The only numeric constraint on the wire,ui/gfx/mojom/transform_mojom_traits.h:62-66, rejects non-finite values on thekMatrixunion branch only; thekAxis2dbranch at:47-55, which is whatTranslate()andScale()produce, has no numeric check.components/viz/service/hit_test/hit_test_manager.cc:128-151checksregions.size()againstkMaxRegionsPerSurface, flag and async-reason coherence, and aclient_id == 0fixup. Its comment at:139-142defers hierarchy checking to aggregation. Geometry is never examined.hit_test_aggregator.cc:165-166runsIsChildOf(submitting_frame_sink_id, region.frame_sink_id)underkRejectInvalidChildRegions,FEATURE_ENABLED_BY_DEFAULTat: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.
git checkout 02c73ef5489a46a07333f133770f3af12dc755degit apply poc.patch, which touches onlycc/trees/hit_test_data_builder.ccgn gen out/pocwithis_debug=false dcheck_always_on=true is_component_build=true enable_nacl=false, thenautoninja -C out/poc chromepython3 server.pyfrom the directory holding the three pages, no arguments, not root- 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. - Attack arm: the same command without
?control=1and 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}
Is the security issue related to a crash?
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/