Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds memory access in Layout
DescriptionOut of bounds memory access in Layout
ComponentLayout
Bug ClassOOB
Tracker498085466
Fix commit21fa20ceb5e9 (chromium/src) +103/-18
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
modified
TEST_F
third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
modified
for
third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
modified
for
third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
modified

Files Changed

  • third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
  • third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
  • third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
  • third_party/blink/renderer/platform/runtime_enabled_features.json5
  • third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
From 21fa20ceb5e922219293e6d12916e9aac0ed5aa0 Mon Sep 17 00:00:00 2001
From: Koji Ishii <kojii@chromium.org>
Date: Mon, 25 May 2026 22:07:41 -0700
Subject: [PATCH] Fix asymmetric bounds check in `ForEachGraphemeClusters`

This patch fixes `ForEachGraphemeClusters` to check both lower
and upper boundaries. Before the change, it was checking only
the left side boundary, and thus it could iterate beyond
given `to` in LTR, so long as the `ShapeResult` has such
cluster indexes.

With the fix, the iteration is limited within the given `from`
and `to`.

The `emphasis.html` is rebasedlined, as it was rendering
emphasis marks twice; once normally and another by excess
rendering when painting the selection.

Fixed: 498085466
Change-Id: Ic31c3544e035145e32552595389bd269775ec91c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7867050
Auto-Submit: Koji Ishii <kojii@chromium.org>
Commit-Queue: Koji Ishii <kojii@chromium.org>
Reviewed-by: Kent Tamura <tkent@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1635934}
---

diff --git a/third_party/blink/renderer/platform/fonts/shaping/shape_result.cc b/third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
index 331b5ec..5267766 100644
--- a/third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
+++ b/third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
@@ -47,6 +47,7 @@
 #include "third_party/blink/renderer/platform/fonts/shaping/glyph_bounds_accumulator.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_run.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_spacing.h"
+#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
 #include "third_party/blink/renderer/platform/text/text_break_iterator.h"
 #include "third_party/blink/renderer/platform/wtf/size_assertions.h"
 #include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
@@ -897,20 +898,27 @@
     const unsigned num_glyphs = run->glyph_data_.size();
     for (unsigned i = 0; i < num_glyphs; ++i) {
       const HarfBuzzRunGlyphData& glyph_data = run->glyph_data_[i];
-      uint16_t current_character_index =
+      const uint16_t current_character_index =
           run->start_index_ + glyph_data.character_index + run_offset;
-      bool is_run_end = (i + 1 == num_glyphs);
-      bool is_cluster_end =
-          is_run_end || (run->GlyphToCharacterIndex(i + 1) + run_offset !=
-                         current_character_index);
-
-      if ((rtl && current_character_index >= to) ||
-          (!rtl && current_character_index < from)) {
+      const bool is_bounds_check_enabled =
+          RuntimeEnabledFeatures::GraphemeClusterBoundsCheckEnabled();
+      if (is_bounds_check_enabled && current_character_index >= text.length())
+          [[unlikely]] {
+        NOTREACHED();
+      }
+      if (is_bounds_check_enabled
+              ? current_character_index < from || current_character_index >= to
+              : (rtl && current_character_index >= to) ||
+                    (!rtl && current_character_index < from)) {
         advance_so_far += glyph_data.advance;
         rtl ? --cluster_start : ++cluster_start;
         continue;
       }
 
+      const bool is_run_end = (i + 1 == num_glyphs);
+      const bool is_cluster_end =
+          is_run_end || (run->GlyphToCharacterIndex(i + 1) + run_offset !=
+                         current_character_index);
       cluster_advance += glyph_data.advance;
 
       if (text.Is8Bit()) {
diff --git a/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc b/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
index cd6df4a..658aa2b3 100644
--- a/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
+++ b/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_run.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_spacing.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_test_info.h"
+#include "third_party/blink/renderer/platform/fonts/shaping/shape_result_view.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/font_test_base.h"
@@ -942,4 +943,39 @@
   EXPECT_EQ(cursor.GlyphData().glyph, 24u);
 }
 
+TEST_F(ShapeResultTest, ForEachGraphemeClustersBoundsCheck) {
+  ShapeResult* result =
+      MakeGarbageCollected<ShapeResult>(0, 10, TextDirection::kLtr);
+  result->InsertRunForTesting(0, 10, TextDirection::kLtr);
+  const String text = "0123456789";
+
+  struct Context {
+    Vector<unsigned> called_indices;
+  };
+  const auto callback = [](void* context_ptr, unsigned character_index,
+                           float total_advance, unsigned graphemes_in_cluster,
+                           float cluster_advance,
+                           CanvasRotationInVertical rotation) {
+    auto* ctx = static_cast<Context*>(context_ptr);
+    ctx->called_indices.push_back(character_index);
+  };
+  {
+    Context context;
+    result->ForEachGraphemeClusters(text, 0.0f, 0, 8, 0, callback, &context);
+    EXPECT_EQ(context.called_indices.size(), 8u);
+    for (unsigned i = 0; i < context.called_indices.size(); ++i) {
+      EXPECT_EQ(context.called_indices[i], i);
+    }
+  }
+  {
+    const ShapeResultView* view = ShapeResultView::Create(result);
+    Context context;
+    view->ForEachGraphemeClusters(text, 0.0f, 0, 8, 0, callback, &context);
+    EXPECT_EQ(context.called_indices.size(), 8u);
+    for (unsigned i = 0; i < 8; ++i) {
+      EXPECT_EQ(context.called_indices[i], i);
+    }
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc b/third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
index a21a16a..4f59d58b 100644
--- a/third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
+++ b/third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
@@ -13,6 +13,7 @@
 #include "third_party/blink/renderer/platform/fonts/font.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/glyph_bounds_accumulator.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_run.h"
+#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
 #include "third_party/blink/renderer/platform/text/character_break_iterator.h"
 #include "ui/gfx/geometry/skia_conversions.h"
 
@@ -532,22 +533,28 @@
     const unsigned num_glyphs = part.NumGlyphs();
     for (unsigned i = 0; i < num_glyphs; ++i) {
       const HarfBuzzRunGlyphData& glyph_data = part.GlyphAt(i);
-      uint16_t current_character_index =
+      const uint16_t current_character_index =
           glyph_data.character_index + character_index_offset_for_glyph_data;
-
-      bool is_run_end = (i + 1 == num_glyphs);
-      bool is_cluster_end =
-          is_run_end || (part.GlyphAt(i + 1).character_index +
-                             character_index_offset_for_glyph_data !=
-                         current_character_index);
-
-      if ((rtl && current_character_index >= to) ||
-          (!rtl && current_character_index < from)) {
+      const bool is_bounds_check_enabled =
+          RuntimeEnabledFeatures::GraphemeClusterBoundsCheckEnabled();
+      if (is_bounds_check_enabled && current_character_index >= text.length())
+          [[unlikely]] {
+        NOTREACHED();
+      }
+      if (is_bounds_check_enabled
+              ? current_character_index < from || current_character_index >= to
+              : (rtl && current_character_index >= to) ||
+                    (!rtl && current_character_index < from)) {
         advance_so_far += glyph_data.advance.ToFloat();
         rtl ? --cluster_start : ++cluster_start;
         continue;
       }
 
+      const bool is_run_end = (i + 1 == num_glyphs);
+      const bool is_cluster_end =
+          is_run_end || (part.GlyphAt(i + 1).character_index +
+                             character_index_offset_for_glyph_data !=
+                         current_character_index);
       cluster_advance += glyph_data.advance.ToFloat();
 
       if (text.Is8Bit()) {
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 4fd8f184..041655d5 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -3214,6 +3214,10 @@
       status: "stable",
     },
     {
+      name: "GraphemeClusterBoundsCheck",
+      status: "stable",
+    },
+    {
       name: "GroupEffect",
       status: "test",
     },
diff --git a/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
index f7b6dbf..57ec9f15 100644
--- a/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/mac-mac12/inspector-protocol/emulation/device-emulation-device-posture-expected.txt b/third_party/blink/web_tests/platform/mac-mac12/inspector-protocol/emulation/device-emulation-device-posture-expected.txt
new file mode 100644
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc b/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
index cd6df4a..658aa2b3 100644
--- a/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
+++ b/third_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc
@@ -12,6 +12,7 @@
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_run.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_spacing.h"
 #include "third_party/blink/renderer/platform/fonts/shaping/shape_result_test_info.h"
+#include "third_party/blink/renderer/platform/fonts/shaping/shape_result_view.h"
 #include "third_party/blink/renderer/platform/heap/garbage_collected.h"
 #include "third_party/blink/renderer/platform/heap/persistent.h"
 #include "third_party/blink/renderer/platform/testing/font_test_base.h"
@@ -942,4 +943,39 @@
   EXPECT_EQ(cursor.GlyphData().glyph, 24u);
 }
 
+TEST_F(ShapeResultTest, ForEachGraphemeClustersBoundsCheck) {
+  ShapeResult* result =
+      MakeGarbageCollected<ShapeResult>(0, 10, TextDirection::kLtr);
+  result->InsertRunForTesting(0, 10, TextDirection::kLtr);
+  const String text = "0123456789";
+
+  struct Context {
+    Vector<unsigned> called_indices;
+  };
+  const auto callback = [](void* context_ptr, unsigned character_index,
+                           float total_advance, unsigned graphemes_in_cluster,
+                           float cluster_advance,
+                           CanvasRotationInVertical rotation) {
+    auto* ctx = static_cast<Context*>(context_ptr);
+    ctx->called_indices.push_back(character_index);
+  };
+  {
+    Context context;
+    result->ForEachGraphemeClusters(text, 0.0f, 0, 8, 0, callback, &context);
+    EXPECT_EQ(context.called_indices.size(), 8u);
+    for (unsigned i = 0; i < context.called_indices.size(); ++i) {
+      EXPECT_EQ(context.called_indices[i], i);
+    }
+  }
+  {
+    const ShapeResultView* view = ShapeResultView::Create(result);
+    Context context;
+    view->ForEachGraphemeClusters(text, 0.0f, 0, 8, 0, callback, &context);
+    EXPECT_EQ(context.called_indices.size(), 8u);
+    for (unsigned i = 0; i < 8; ++i) {
+      EXPECT_EQ(context.called_indices[i], i);
+    }
+  }
+}
+
 }  // namespace blink
diff --git a/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
index f7b6dbf..57ec9f15 100644
--- a/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/mac-mac12/inspector-protocol/emulation/device-emulation-device-posture-expected.txt b/third_party/blink/web_tests/platform/mac-mac12/inspector-protocol/emulation/device-emulation-device-posture-expected.txt
new file mode 100644
index 0000000..e658bd0
--- /dev/null
+++ b/third_party/blink/web_tests/platform/mac-mac12/inspector-protocol/emulation/device-emulation-device-posture-expected.txt
@@ -0,0 +1,14 @@
+Tests that device emulation of device posture is propagated and powers Device Posture API.
+Main frame Posture:
+continuous
+Iframe Posture:
+continuous
+Main frame Posture:
+folded
+Iframe Posture:
+continuous
+Main frame Posture:
+continuous
+Iframe Posture:
+continuous
+
diff --git a/third_party/blink/web_tests/platform/mac-mac14-arm64/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/mac-mac14-arm64/virtual/text-antialias/selection/emphasis-expected.png
index a700952..589c2143 100644
--- a/third_party/blink/web_tests/platform/mac-mac14-arm64/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/mac-mac14-arm64/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/mac-mac14/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/mac-mac14/virtual/text-antialias/selection/emphasis-expected.png
index 5d95ed8..589c2143 100644
--- a/third_party/blink/web_tests/platform/mac-mac14/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/mac-mac14/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/mac/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/mac/virtual/text-antialias/selection/emphasis-expected.png
index 43c7247..d4e99df 100644
--- a/third_party/blink/web_tests/platform/mac/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/mac/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/win/virtual/text-antialias/selection/emphasis-expected.png b/third_party/blink/web_tests/platform/win/virtual/text-antialias/selection/emphasis-expected.png
index 1a6a0fda..0532ebb3 100644
--- a/third_party/blink/web_tests/platform/win/virtual/text-antialias/selection/emphasis-expected.png
+++ b/third_party/blink/web_tests/platform/win/virtual/text-antialias/selection/emphasis-expected.png
Binary files differ
diff --git a/third_party/blink/web_tests/platform/win11-arm64/external/wpt/fetch/fetch-later/new-window.https.window-expected.txt b/third_party/blink/web_tests/platform/win11-arm64/external/wpt/fetch/fetch-later/new-window.https.window-expected.txt
new file mode 100644
index 0000000..ee5b8249
--- /dev/null
+++ b/third_party/blink/web_tests/platform/win11-arm64/external/wpt/fetch/fetch-later/new-window.https.window-expected.txt
@@ -0,0 +1,11 @@
+This is a testharness.js-based test.
+[FAIL] A cross-origin window[target=''][features=''] can trigger fetchLater.
+  assert_equals: Number of sent beacons does not match expected count: expected 1 but got 0
+[FAIL] A cross-origin window[target=''][features='popup'] can trigger fetchLater.
+  assert_equals: Number of sent beacons does not match expected count: expected 1 but got 0
+[FAIL] A cross-origin window[target='_blank'][features=''] can trigger fetchLater.
+  assert_equals: Number of sent beacons does not match expected count: expected 1 but got 0
+[FAIL] A cross-origin window[target='_blank'][features='popup'] can trigger fetchLater.
+  assert_equals: Number of sent beacons does not match expected count: expected 1 but got 0
+Harness: the test ran to completion.
+
diff --git a/third_party/blink/web_tests/platform/win11-arm64/external/wpt/visual-viewport/page-and-offset-in-iframe-expected.txt b/third_party/blink/web_tests/platform/win11-arm64/external/wpt/visual-viewport/page-and-offset-in-iframe-expected.txt
new file mode 100644
index 0000000..6fda965
--- /dev/null
+++ b/third_party/blink/web_tests/platform/win11-arm64/external/wpt/visual-viewport/page-and-offset-in-iframe-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+[FAIL] VisualViewport page and offset values in iframe
+  assert_greater_than: Pinch zoom must have increased scale expected a number greater than 1.2 but got 1
+Harness: the test ran to completion.
+
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer heap OOB read in CSS text-emphasis rendering via malformed font

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential 1-bit heap information leak exists in the Blink renderer’s text-emphasis rendering path. By providing a crafted font that produces out-of-range HarfBuzz cluster indices, an attacker can bypass asymmetric bounds checks and read adjacent heap memory. This out-of-bounds read acts as an oracle to classify renderer heap contents, aiding in ASLR bypass.

Affected files:

  • third_party/blink/renderer/platform/fonts/shaping/shape_result_bloberizer.cc
  • third_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc
  • third_party/blink/renderer/platform/fonts/shaping/shape_result.cc
  • third_party/blink/renderer/platform/fonts/shaping/glyph_data.h
  • third_party/blink/renderer/platform/wtf/text/string_view.h

Estimated timestamp from git blame: 2026-03-18

Summary

An out-of-bounds (OOB) heap read vulnerability potentially exists in the Blink renderer when painting CSS text-emphasis marks. The issue stems from a combination of improper handling of non-monotonic font cluster indices, an integer underflow, an asymmetric bounds check, and the reliance on SECURITY_DCHECK in release builds. This allows an attacker to read up to ~32KB past an 8-bit string buffer on the PartitionAlloc heap and leak information via a 1-bit visual oracle.

Vulnerability Details

  1. Non-Monotonic Cluster Indices: In third_party/blink/renderer/platform/fonts/shaping/shape_result.cc, ShapeResultRun::LimitNumGlyphs processes glyphs returned by HarfBuzz. It uses std::upper_bound on the cluster indices, which assumes they are monotonically increasing. A maliciously crafted font can violate this assumption, resulting in a selected range where a glyph.cluster is less than the start_cluster of the run.

  2. Integer Underflow: In ShapeResult::ComputeGlyphPositions, the relative character index is calculated as const uint16_t character_index = glyph.cluster - start_cluster;. Because glyph.cluster < start_cluster, this underflows, creating a large positive index (up to 32,767). DCHECKs designed to catch this compile out in release builds, allowing the corrupt index to be stored in the HarfBuzzRunGlyphData bitfield.

  3. Asymmetric Bounds Check: During painting, ShapeResultView::ForEachGraphemeClusters (shape_result_view.cc) iterates over the glyphs. For Left-to-Right (LTR) text, it employs an asymmetric bounds check:

    if ((rtl && current_character_index >= to) ||
        (!rtl && current_character_index < from)) {
        continue;
    }
    

    It fails to check if current_character_index >= to for LTR text. Since our corrupted index is a large positive number, it passes this check.

  4. 8-bit Fast Path Bypass: For 8-bit (Latin-1) text, ForEachGraphemeClusters takes a fast path that skips safe StringView bounds checking (which relies on base::span). Instead, it directly invokes the AddEmphasisMarkToBloberizer callback with the out-of-bounds index.

  5. OOB Read via SECURITY_DCHECK: The callback accesses the string using text[character_index]. StringView::operator[] (string_view.h) relies on SECURITY_DCHECK(i < length()). In standard release builds, SECURITY_DCHECK is a no-op, leading to a raw pointer dereference inside an UNSAFE_BUFFERS block that reads out-of-bounds PartitionAlloc heap memory.

  6. Information Oracle: The OOB byte is passed to Character::CanReceiveTextEmphasis, which returns false for ~67 specific byte values (like control characters) and true for others. If true, an emphasis mark is painted. This creates a 1-bit visual oracle.

Suggested Attacker Steps

Note: These are potential steps based on code analysis; our tooling agent does not currently have the capability to run a live Proof of Concept.

  1. Load a Crafted Font: The attacker embeds a malicious web font (e.g., WOFF2) designed to return out-of-order HarfBuzz cluster indices during shaping.
  2. Trigger Text-Emphasis: Apply the font to a short Latin-1 text node and set the CSS property text-emphasis-style: dot.
  3. Heap Grooming: Groom the PartitionAlloc heap to place sensitive target data (e.g., pointers) immediately following the text string buffer.
  4. Observe the Oracle: Force a layout/paint. Use the Canvas 2D API (getImageData) or getBoundingClientRect() to detect if an emphasis mark was rendered for the anomalous glyph. This reveals 1 bit of information about the OOB byte, which can be repeated to bypass ASLR.

Suggested Fix

  1. Fix Asymmetric Bounds Check: In ShapeResultView::ForEachGraphemeClusters, ensure both lower and upper bounds are checked regardless of text direction:
    if (current_character_index < from || current_character_index >= to) { continue; }
    
  2. Harden StringView: Upgrade the SECURITY_DCHECK in StringView::operator[] to a CHECK or SECURITY_CHECK to ensure bounds are strictly enforced in release builds.
  3. Validate Monotonicity: Add robust checks in LimitNumGlyphs to handle or reject fonts that produce non-monotonic cluster indices safely.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker