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

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in CSS
DescriptionUse after free in CSS
ComponentCSS
Sub-componentRouteMatching
Bug ClassUAF
Action tierwatch — corruption primitive but gated (flag) — 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.
Tracker467297219
Fix commit7710041f9821 (chromium/src) +97/-32
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
CreditedHan Zheng (HexHive), Wenhao Fang (University of St. Andrews), and Qinying Wang (HexHive)
Reported2025-12-09 · bounty $8000 (per vendor advisory)
Fix landed2025-12-10 (public git)
Disclosed2026-02-10
Patch gap62 days — fix public in source before the advisory

Background

Route matching
a Blink feature (third_party/blink/renderer/core/route_matching/) backing CSS navigation routes, where a RouteMap holds named Route objects whose URL patterns are matched against the current/next navigation URLs.
`<script type="routemap">`
a JSON block that declares routes; parsing it calls RouteMap::ParseAndApplyRoutes, which adds routes and patterns to the map.
`activate` / `deactivate` events
RouteEvents dispatched on a Route when its “matches at” status changes, running page JavaScript listeners synchronously.
`RouteMap::routes_` / `anonymous_routes_`
Oilpan heap hash maps of routes that RouteMap::UpdateActiveRoutes iterates to refresh each route’s match status.

Root Cause Analysis

Before the patch, RouteMap::UpdateActiveRoutes iterated routes_ and anonymous_routes_ and called the route’s match-status update for each entry, and that update (in route.cc; the function name is not shown in the hunk but it is the bool method that sets matches_at_, called as route.UpdateMatchStatus(previous_url_, next_url_)) synchronously dispatched an activate/deactivate RouteEvent via DispatchEvent. A listener could therefore run script in the middle of the iteration; as the regression test shows, an activate handler that appends a new <script type="routemap"> reached RouteMap::ParseAndApplyRoutes, which added a route and then called UpdateActiveRoutes() again, re-entering it while the outer loop was still iterating. The commit message states this recursion could modify (rehash) the route set while it was being iterated, which leaves the outer loop using an invalidated iterator into a freed hash-table backing store (inferred, consistent with the advisory’s “Use after free”).

The fix removes event dispatch from the per-route update, has the new RouteMap::UpdateMatchStatus helper only collect routes whose kAt match flipped into a local routes_needing_event vector, and dispatches the events only after both map iterations finish. It also stops ParseAndApplyRoutes from calling UpdateActiveRoutes(); new patterns instead update just their own route’s status in the new RouteMap::AddPatternToRoute without firing events, and a DCHECK(!is_updating_active_routes_) guards against future recursion.

Key insight
Script-visible events were dispatched synchronously from inside an iteration over the route hash maps, letting a handler mutate those maps mid-iteration; the fix defers event dispatch until the iteration is complete and removes the re-entrant UpdateActiveRoutes() call from route parsing.

Attack Path

  1. Declare a route A page declares a route via <script type="routemap"> whose pattern will match a URL the page can navigate to.
  2. Register a re-entrant listener The page adds an activate listener on that Route that inserts another <script type="routemap">, adding a new route.
  3. Trigger matching A same-document navigation such as history.pushState causes UpdateActiveRoutes to run and the route’s status change to dispatch activate from within the iteration over routes_.
  4. Mutation during iteration The handler’s new routemap re-enters ParseAndApplyRoutes → UpdateActiveRoutes, inserting into the map being iterated and potentially rehashing it.
  5. Use after free When control returns, the outer loop continues with an iterator into the old, freed backing store, giving a renderer use-after-free (inferred).

Impact Assessment

A malicious page could trigger a use-after-free in the renderer process (the vendor rates this high severity, “Use after free in CSS”; the advisory provided does not state in-the-wild exploitation), with memory corruption and possible code execution inside the sandboxed renderer being (inferred) rather than shown by the patch. The trigger needs only page-controlled script, a <script type="routemap">, and a same-document navigation such as history.pushState, with no user gesture shown in the regression test. The diff does not show whether route matching is enabled by default in stock builds; the test lives under wpt_internal and the commit calls <script type="routemap"> likely to go away, suggesting an experimental feature, so reachability may depend on a runtime feature being enabled (inferred).

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/core/route_matching/route_map.cc
modified
if
third_party/blink/renderer/core/route_matching/route_map.cc
modified

Files Changed

  • third_party/blink/renderer/core/route_matching/route.cc
  • third_party/blink/renderer/core/route_matching/route_map.cc
  • third_party/blink/renderer/core/route_matching/route_map.h

Audit Directions

  • Event dispatch inside container iteration
    Search Blink for DispatchEvent (or other script-running callbacks) reached from loops over HeapHashMap/HeapHashSet/HeapVector members, where a listener can add or remove entries.
  • Parse-then-apply re-entrancy
    Audit other declarative-script or style parsers (e.g. speculation rules, import maps, the planned @route rule) that synchronously trigger state updates which fire events, allowing nested insertion during processing.
  • Release-mode recursion guards
    The new re-entrancy check is DCHECK-only; review similar UpdateX methods to ensure correctness does not rely on debug-only guards and that deferred-event patterns are used consistently.
From 7710041f98212312f5810c831adca08157348975 Mon Sep 17 00:00:00 2001
From: Morten Stenshorne <[email protected]>
Date: Wed, 10 Dec 2025 08:53:37 -0800
Subject: [PATCH] [RouteMatching] Avoid recursion.

If another route was added in a route event handler, we'd end up
invoking RouteMap::UpdateMatchStatus() while already inside it. This is
bad. Fixing it by making a local copy of the routes and then walking
that set in UpdateMatchStatus() to prevent the set from being modified
(rehashed) while iterating it might seem compelling, but recursion here
doesn't seem good for correctness and code maintainability anyway.

Instead, fire route events as a separate step after having updated the
match status for all routes. Also do NOT trigger UpdateMatchStatus()
when adding new routes from <script>. Just update the match status with
no events being fired. Be sure to trigger a lifecycle update if anything
has changed, though.

<script type="routemap"> may be going away, in favor of the new @route
rule. See https://drafts.csswg.org/css-navigation-1/#at-route , and as
part of that task we should integrate route matching nicely into the
document lifecycle machinery.

Also add a DCHECK that fails on recursion.

Bug: 467297219
Change-Id: I604d3b29bf4a1cabf83a5c13f32c1dc01dbe84e8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7246274
Commit-Queue: Morten Stenshorne <[email protected]>
Reviewed-by: Noam Rosenthal <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1556793}
---

diff --git a/third_party/blink/renderer/core/route_matching/route.cc b/third_party/blink/renderer/core/route_matching/route.cc
index 07987d6..bff1cbc 100644
--- a/third_party/blink/renderer/core/route_matching/route.cc
+++ b/third_party/blink/renderer/core/route_matching/route.cc
@@ -9,7 +9,6 @@
 #include "third_party/blink/renderer/core/dom/document.h"
 #include "third_party/blink/renderer/core/event_target_names.h"
 #include "third_party/blink/renderer/core/execution_context/execution_context.h"
-#include "third_party/blink/renderer/core/route_matching/route_event.h"
 #include "third_party/blink/renderer/core/url_pattern/url_pattern.h"
 #include "third_party/blink/renderer/platform/weborigin/kurl.h"
 
@@ -78,10 +77,6 @@
   }
 
   matches_at_ = matches_at;
-  AtomicString type(matches_at_ ? "activate" : "deactivate");
-  auto* event = MakeGarbageCollected<RouteEvent>(type);
-  event->SetTarget(this);
-  DispatchEvent(*event);
   return true;
 }
 
diff --git a/third_party/blink/renderer/core/route_matching/route_map.cc b/third_party/blink/renderer/core/route_matching/route_map.cc
index 9e0c514..338e41e 100644
--- a/third_party/blink/renderer/core/route_matching/route_map.cc
+++ b/third_party/blink/renderer/core/route_matching/route_map.cc
@@ -4,11 +4,13 @@
 
 #include "third_party/blink/renderer/core/route_matching/route_map.h"
 
+#include "base/auto_reset.h"
 #include "base/check_is_test.h"
 #include "third_party/blink/renderer/core/css/style_engine.h"
 #include "third_party/blink/renderer/core/dom/document.h"
 #include "third_party/blink/renderer/core/execution_context/execution_context.h"
 #include "third_party/blink/renderer/core/route_matching/route.h"
+#include "third_party/blink/renderer/core/route_matching/route_event.h"
 #include "third_party/blink/renderer/core/url_pattern/url_pattern.h"
 #include "third_party/blink/renderer/core/url_pattern/url_pattern_utils.h"
 #include "third_party/blink/renderer/platform/json/json_parser.h"
@@ -18,20 +20,6 @@
 
 namespace {
 
-RouteMap::ParseResult AddPatternToRoute(const Document& document,
-                                        Route& route,
-                                        const JSONValue& value) {
-  base::expected<URLPattern*, String> pattern =
-      ParseURLPatternFromJSON(document.GetExecutionContext()->GetIsolate(),
-                              value, document.Url(), IGNORE_EXCEPTION);
-  if (pattern.has_value()) {
-    DCHECK(*pattern);
-    route.AddPattern(*pattern);
-    return RouteMap::ParseResult(RouteMap::ParseResult::kSuccess);
-  }
-  return RouteMap::ParseResult(RouteMap::ParseResult::kSyntaxError,
-                               pattern.error());
-}
 
 }  // anonymous namespace
 
@@ -80,12 +68,6 @@
 
 RouteMap::ParseResult RouteMap::ParseAndApplyRoutes(
     const String& route_map_text) {
-  RouteMap::ParseResult result = ParseRoutes(route_map_text);
-  UpdateActiveRoutes();
-  return result;
-}
-
-RouteMap::ParseResult RouteMap::ParseRoutes(const String& route_map_text) {
   constexpr char kPattern[] = "pattern";
   std::unique_ptr<JSONValue> value = ParseJSON(route_map_text);
   // TODO(crbug.com/436805487): Error reporting needs to be specced. Should we
@@ -136,8 +118,7 @@
                              "Missing pattern in route entry");
         }
         for (const JSONValue& pattern : *patterns) {
-          ParseResult result =
-              AddPatternToRoute(GetDocument(), *route, pattern);
+          ParseResult result = AddPatternToRoute(*route, pattern);
           if (!result.IsSuccess()) {
             return result;
           }
@@ -149,7 +130,7 @@
           return ParseResult(ParseResult::kTypeError,
                              "Missing pattern in route entry");
         }
-        ParseResult result = AddPatternToRoute(GetDocument(), *route, *pattern);
+        ParseResult result = AddPatternToRoute(*route, *pattern);
         if (!result.IsSuccess()) {
           return result;
         }
@@ -188,15 +169,30 @@
 }
 
 void RouteMap::UpdateActiveRoutes() {
+#if DCHECK_IS_ON()
+  DCHECK(!is_updating_active_routes_);
+  base::AutoReset<bool> is_updating(&is_updating_active_routes_, true);
+#endif
+
+  HeapVector<Member<Route>> routes_needing_event;
   bool changed = false;
   for (const auto& entry : routes_) {
     Route& route = *entry.value;
-    changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
+    changed |= UpdateMatchStatus(route, &routes_needing_event);
   }
   for (const auto& entry : anonymous_routes_) {
     Route& route = *entry.value;
-    changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
+    changed |= UpdateMatchStatus(route, &routes_needing_event);
   }
+
+  for (Route* route : routes_needing_event) {
+    bool matches_at = route->Matches(NavigationPreposition::kAt);
+    AtomicString type(matches_at ? "activate" : "deactivate");
+    auto* event = MakeGarbageCollected<RouteEvent>(type);
+    event->SetTarget(route);
+    route->DispatchEvent(*event);
+  }
+
   if (changed) {
     GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
   }
@@ -220,4 +216,36 @@
   }
 }
 
+RouteMap::ParseResult RouteMap::AddPatternToRoute(Route& route,
+                                                  const JSONValue& value) {
+  base::expected<URLPattern*, String> pattern =
+      ParseURLPatternFromJSON(GetDocument().GetExecutionContext()->GetIsolate(),
+                              value, GetDocument().Url(), IGNORE_EXCEPTION);
+  if (pattern.has_value()) {
+    DCHECK(*pattern);
+    route.AddPattern(*pattern);
+    // TODO(crbug.com/436805487): If we actually end up keeping support for
+    // <script type="routemap">, we're missing events here.
+    if (route.UpdateMatchStatus(previous_url_, next_url_)) {
+      GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
+    }
+    return RouteMap::ParseResult(RouteMap::ParseResult::kSuccess);
+  }
+  return RouteMap::ParseResult(RouteMap::ParseResult::kSyntaxError,
+                               pattern.error());
+}
+
+bool RouteMap::UpdateMatchStatus(
+    Route& route,
+    HeapVector<Member<Route>>* routes_needing_event) {
+  bool matched_at = route.Matches(NavigationPreposition::kAt);
+  if (!route.UpdateMatchStatus(previous_url_, next_url_)) {
+    return false;
+  }
+  if (matched_at != route.Matches(NavigationPreposition::kAt)) {
+    routes_needing_event->push_back(&route);
+  }
+  return true;
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/route_matching/route_map.h b/third_party/blink/renderer/core/route_matching/route_map.h
index 6023f3e..843ea1d 100644
--- a/third_party/blink/renderer/core/route_matching/route_map.h
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html b/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html
new file mode 100644
index 0000000..454b55d
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/route/crashtests/add-route-on-activate.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<link rel="help" href="https://issues.chromium.org/issues/467297219">
+<script type="routemap">
+  {
+    "routes": [
+      {
+        "name": "initial-route",
+        "pattern": { "pathname": "/activate-me" }
+      }
+    ]
+  }
+</script>
+
+PASS
+
+<script>
+  const DYNAMIC_JSON = JSON.stringify({
+    "routes": [
+      {
+        "name": "dynamic-route",
+        "pattern": { "pathname": "/dynamic-path" }
+      }
+    ]
+  });
+
+  const route = document.routeMap.get('initial-route');
+  route.addEventListener('activate', (e) => {
+    const script = document.createElement('script');
+    script.type = "routemap";
+    script.textContent = DYNAMIC_JSON;
+    document.head.appendChild(script);
+  });
+  history.pushState({}, '', '/activate-me');
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Poison in RouteMap::UpdateActiveRoutes

Steps to reproduce the problem

  1. open test.html with flag –enable-blink-features=RouteMatching
  2. click button
  3. UAP!

Problem Description

void RouteMap::UpdateActiveRoutes() {
  bool changed = false;
  for (const auto& entry : routes_) {
    Route& route = *entry.value;
    changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed; //<= trigger JS
  }
  for (const auto& entry : anonymous_routes_) {
    Route& route = *entry.value;
    changed = route.UpdateMatchStatus(previous_url_, next_url_) || changed;
  }
  if (changed) {
    GetDocument().GetStyleEngine().NavigationsMayHaveChanged();
  }
}

// JS trigger this snippet

      if (it == routes_.end()) {
        routes_.insert(name, route); // <= insert to container, rehash happen and invalidate all iterators
      }
   

Summary

Use-After-Poison in RouteMap::UpdateActiveRoutes

Custom Questions

Type of crash:

tab

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker