CVE-2026-13873
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/platform/fonts/shaping/shape_result.cc |
modified | |
TEST_Fthird_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc |
modified | |
forthird_party/blink/renderer/platform/fonts/shaping/shape_result_test.cc |
modified | |
forthird_party/blink/renderer/platform/fonts/shaping/shape_result_view.cc |
modified |
Files Changed
third_party/blink/renderer/platform/fonts/shaping/shape_result.ccthird_party/blink/renderer/platform/fonts/shaping/shape_result_test.ccthird_party/blink/renderer/platform/fonts/shaping/shape_result_view.ccthird_party/blink/renderer/platform/runtime_enabled_features.json5third_party/blink/web_tests/platform/linux/virtual/text-antialias/selection/emphasis-expected.png
Patch
From 21fa20ceb5e922219293e6d12916e9aac0ed5aa0 Mon Sep 17 00:00:00 2001 From: Koji Ishii <[email protected]> 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 <[email protected]> Commit-Queue: Koji Ishii <[email protected]> Reviewed-by: Kent Tamura <[email protected]> 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
Regression Test / PoC
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.
+
Original Bug Report
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.ccthird_party/blink/renderer/platform/fonts/shaping/shape_result_view.ccthird_party/blink/renderer/platform/fonts/shaping/shape_result.ccthird_party/blink/renderer/platform/fonts/shaping/glyph_data.hthird_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
-
Non-Monotonic Cluster Indices: In
third_party/blink/renderer/platform/fonts/shaping/shape_result.cc,ShapeResultRun::LimitNumGlyphsprocesses glyphs returned by HarfBuzz. It usesstd::upper_boundon the cluster indices, which assumes they are monotonically increasing. A maliciously crafted font can violate this assumption, resulting in a selected range where aglyph.clusteris less than thestart_clusterof the run. -
Integer Underflow: In
ShapeResult::ComputeGlyphPositions, the relative character index is calculated asconst uint16_t character_index = glyph.cluster - start_cluster;. Becauseglyph.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 theHarfBuzzRunGlyphDatabitfield. -
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 >= tofor LTR text. Since our corrupted index is a large positive number, it passes this check. -
8-bit Fast Path Bypass: For 8-bit (Latin-1) text,
ForEachGraphemeClusterstakes a fast path that skips safeStringViewbounds checking (which relies onbase::span). Instead, it directly invokes theAddEmphasisMarkToBloberizercallback with the out-of-bounds index. -
OOB Read via SECURITY_DCHECK: The callback accesses the string using
text[character_index].StringView::operator[](string_view.h) relies onSECURITY_DCHECK(i < length()). In standard release builds,SECURITY_DCHECKis a no-op, leading to a raw pointer dereference inside anUNSAFE_BUFFERSblock that reads out-of-bounds PartitionAlloc heap memory. -
Information Oracle: The OOB byte is passed to
Character::CanReceiveTextEmphasis, which returnsfalsefor ~67 specific byte values (like control characters) andtruefor others. Iftrue, 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.
- 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.
- Trigger Text-Emphasis: Apply the font to a short Latin-1 text node and set the CSS property
text-emphasis-style: dot. - Heap Grooming: Groom the PartitionAlloc heap to place sensitive target data (e.g., pointers) immediately following the text string buffer.
- Observe the Oracle: Force a layout/paint. Use the Canvas 2D API (
getImageData) orgetBoundingClientRect()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
- 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; } - Harden StringView: Upgrade the
SECURITY_DCHECKinStringView::operator[]to aCHECKorSECURITY_CHECKto ensure bounds are strictly enforced in release builds. - Validate Monotonicity: Add robust checks in
LimitNumGlyphsto 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.