High watch CVSS 4.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Fonts
DescriptionOut of bounds read in Fonts
ComponentFonts
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker492350406
Fix commitf74f78b6aeb9 (chromium/src) +10/-20
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-13 (per vendor advisory)
Fix landed2026-03-24 (public git)
Disclosed2026-05-05
Patch gap42 days — fix public in source before the advisory

Background

`StringView`
a Blink WTF non-owning view over a backing StringImpl, holding a raw bytes_ pointer plus a length_, used widely including in font/text processing.
`SECURITY_DCHECK`
a Blink assertion that is active only when security assertions are enabled (debug/assert builds) and compiles to a no-op in ordinary release builds, so it cannot be relied on to catch bad input in shipped Chrome.
`UNSAFE_BUFFERS`
a macro marking a block of raw, unchecked pointer arithmetic that the spanification tooling otherwise forbids, signaling that bounds safety is the author’s manual responsibility.
`subspan(offset, length)`
a hardened base::span method that performs an always-on bounds check that offset/length lie within the span before returning a sub-view whose data() pointer is then safe to take.

Root Cause Analysis

The offsetting StringView constructor StringView(const StringView& view, size_type offset, size_type length) and the StringView::Set(const StringImpl&, size_type offset, size_type length) method both validated their offset/length arguments only with SECURITY_DCHECK(offset <= view.length()) and SECURITY_DCHECK(length <= view.length() - offset), then computed bytes_ = view.Span8().data() + offset (or the 16-bit variant) inside an UNSAFE_BUFFERS block. In a release build the SECURITY_DCHECKs are compiled out, so a caller supplying an offset/length beyond the backing store’s size produced an out-of-bounds bytes_ pointer with no check, and any later read through the resulting StringView read out of bounds.

The fix removes the manual pointer arithmetic and instead derives the pointer from view.Span8().subspan(offset, length).data() (and the Span16 / impl.Span8/Span16 equivalents), so the span’s unconditional bounds check runs in every build before the data() pointer is taken. The change also drops the length - offset subtraction that could itself underflow when offset > length, replacing it with subspan’s internally correct range validation.

Key insight
The core mistake was guarding unchecked raw-pointer arithmetic with SECURITY_DCHECK, an assertion absent from release builds, so out-of-range offset/length silently yielded an out-of-bounds view; routing the pointer through subspan() makes the bounds check always-on and build-independent.

Attack Path

  1. Reach the offsetting view construction Font/text processing code builds a sub-view of a string by invoking the StringView(view, offset, length) constructor or StringView::Set(impl, offset, length) with offset/length derived from attacker-controlled font or text data.
  2. Supply an out-of-range range A crafted input produces an offset/length pair that exceeds the backing StringImpl length (or makes length - offset underflow), which the compiled-out SECURITY_DCHECKs in release do not catch.
  3. Form an out-of-bounds pointer The UNSAFE_BUFFERS arithmetic sets bytes_ to data() + offset past the end of the buffer with no validation.
  4. Read out of bounds A subsequent read through the StringView (comparison, copy, or character access) dereferences the out-of-range pointer, disclosing adjacent heap memory (consistent with the advisory’s “out of bounds read in Fonts”).

Impact Assessment

An attacker who controls font or string data processed in the renderer can cause an out-of-bounds read of adjacent heap memory, an information-disclosure primitive useful for defeating ASLR or leaking sensitive bytes; the advisory rates it high severity and does not report in-the-wild exploitation. The precondition is reaching the offsetting StringView construction with an attacker-influenced out-of-range offset/length, which the release-build absence of SECURITY_DCHECK left unguarded. Any escalation beyond the read is not demonstrated by this patch.

Files Changed

  • third_party/blink/renderer/platform/wtf/text/string_view.h

Audit Directions

  • `SECURITY_DCHECK`-guarded pointer math
    search for SECURITY_DCHECK/DCHECK used as the sole validation immediately preceding UNSAFE_BUFFERS or raw data() + offset arithmetic, since those checks vanish in release builds.
  • Offset/length subtraction underflow
    flag range checks of the form length <= total - offset where total - offset is unsigned and can underflow when offset > total, preferring subspan-style combined validation.
  • Un-spanified view/buffer constructors
    review other WTF and Blink view types (StringView, string/text spans) still using manual offsetting rather than hardened base::span::subspan, which the referenced broader CL (crrev.com/c/7667267) was spanifying.
From f74f78b6aeb94b13f2a706bb4a3701738f6ab36a Mon Sep 17 00:00:00 2001
From: Tom Sepez <tsepez@google.com>
Date: Tue, 24 Mar 2026 09:42:58 -0700
Subject: [PATCH] Avoid unsafe buffers in StringView(view, offset, length) constructor.

Use the subspan() method which will perform these bounds checks
under the covers before extracting a data() pointer.

This is a simpler way to get part of the benefit of the CL at
https://crrev.com/c/7667267 but without the performance impact
of making all the other methods touched in that CL safe.

-- Do the same for StringView::Set().

Bug: 492350406
Change-Id: If2741f7241204862d28f3b662d897f9cba3e0b1c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7695218
Reviewed-by: Dominik Röttsches <drott@chromium.org>
Reviewed-by: Kentaro Hara <haraken@chromium.org>
Commit-Queue: Tom Sepez <tsepez@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1604199}
---

diff --git a/third_party/blink/renderer/platform/wtf/text/string_view.h b/third_party/blink/renderer/platform/wtf/text/string_view.h
index 0ddabed..0b544fda 100644
--- a/third_party/blink/renderer/platform/wtf/text/string_view.h
+++ b/third_party/blink/renderer/platform/wtf/text/string_view.h
@@ -440,16 +440,11 @@
                               size_type offset,
                               size_type length)
     : impl_(view.impl_), length_(length) {
-  SECURITY_DCHECK(offset <= view.length());
-  SECURITY_DCHECK(length <= view.length() - offset);
-  // SAFETY: Invariants are checked last two line.
-  UNSAFE_BUFFERS({
-    if (Is8Bit()) {
-      bytes_ = view.Span8().data() + offset;
-    } else {
-      bytes_ = view.Span16().data() + offset;
-    }
-  });
+  if (Is8Bit()) {
+    bytes_ = view.Span8().subspan(offset, length).data();
+  } else {
+    bytes_ = view.Span16().subspan(offset, length).data();
+  }
 }
 
 inline StringView::StringView(const StringImpl* impl) {
@@ -491,18 +486,13 @@
 inline void StringView::Set(const StringImpl& impl,
                             size_type offset,
                             size_type length) {
-  SECURITY_DCHECK(offset <= impl.length());
-  SECURITY_DCHECK(length <= impl.length() - offset);
   length_ = length;
   impl_ = const_cast<StringImpl*>(&impl);
-  // SAFETY: Invariants are checked at beginning of this method.
-  UNSAFE_BUFFERS({
-    if (impl.Is8Bit()) {
-      bytes_ = impl.Characters8() + offset;
-    } else {
-      bytes_ = impl.Characters16() + offset;
-    }
-  });
+  if (impl.Is8Bit()) {
+    bytes_ = impl.Span8().subspan(offset, length).data();
+  } else {
+    bytes_ = impl.Span16().subspan(offset, length).data();
+  }
 }
 
 // Unicode aware case insensitive string matching. Non-ASCII characters might
Loading diff…

Original Bug Report

reported by je...@gmail.com

OOB read in ShapeResultView::ForEachGraphemeClusters via multi-glyph cluster skip logic

OOB read in ShapeResultView::ForEachGraphemeClusters via multi-glyph cluster skip logic

Summary

An out-of-bounds read in the Chromium renderer can be triggered from JavaScript on all platforms. The function ShapeResultView::ForEachGraphemeClusters advances the cluster_start position once per glyph instead of once per cluster when skipping glyphs outside the drawing range. When a custom web font uses OpenType GSUB Multiple Substitution to map a single character to multiple glyphs, the skip loop over-advances cluster_start past the actual character boundary. This corrupted position is then used to construct a StringView that extends beyond the underlying text buffer. The subsequent NumGraphemeClusters call iterates over this out-of-bounds view, reading adjacent heap memory. In release builds, SECURITY_DCHECK is a no-op and the OOB read proceeds silently, making this exploitable as an information disclosure primitive from web content.

Bisect

Introducing Commit: 2ef8b65de690c3a2d83bd6b991763359a7c1f3d6

The bug was introduced in the original ShapeResult::ForEachGraphemeClusters API and then copied into ShapeResultView::ForEachGraphemeClusters in commit a365a8203ccd7 (2018-10-31). Both copies contain the same flaw.

Root Cause

ShapeResultView::ForEachGraphemeClusters iterates over glyphs and maintains a cluster_start variable to track the character offset of the current cluster. When a glyph falls outside the [from, to) drawing range, the function skips it and adjusts cluster_start:

// shape_result_view.cc — skip path for out-of-range glyphs
if ((rtl && current_character_index >= to) ||
    (!rtl && current_character_index < from)) {
  advance_so_far += glyph_data.advance.ToFloat();
  rtl ? --cluster_start : ++cluster_start;  // BUG: per-glyph, not per-cluster
  continue;
}

The increment/decrement is executed once for each glyph in the skip region. This is correct when every character produces exactly one glyph, but incorrect for multi-glyph clusters. With GSUB Multiple Substitution (lookup type 2), a single input character can produce an arbitrary number of output glyphs, all sharing the same character_index. In that case, the skip loop runs N times for N glyphs, advancing cluster_start by N when it should advance by 1.

After the skip loop, when the function encounters the first in-range cluster, it computes cluster_end from the next character boundary and constructs a StringView to count grapheme clusters:

// shape_result_view.cc — StringView construction with corrupted cluster_start
graphemes_in_cluster = NumGraphemeClusters(
    cluster_end >= cluster_start
        ? StringView(text, cluster_start, cluster_end - cluster_start)
        : StringView(text, cluster_end, cluster_start - cluster_end));

With cluster_start over-advanced, the arguments to StringView produce a view that starts at or beyond the end of the text buffer. The StringView range constructor guards this with SECURITY_DCHECK:

// string_view.h — the only protection
SECURITY_DCHECK(offset <= view.length());
SECURITY_DCHECK(length <= view.length() - offset);

SECURITY_DCHECK is compiled to a fatal check only when ADDRESS_SANITIZER is defined or DCHECK_IS_ON() is true. In production release builds, both conditions are false and the macro expands to ((void)0), so the out-of-bounds StringView is silently created. NumGraphemeClusters then creates an ICU CharacterBreakIterator over this view, iterating past the text buffer and reading heap memory that follows the StringImpl allocation.

A concrete trigger uses a two-character 16-bit string "\u0100B" with a web font whose ccmp feature decomposes U+0100 into three glyphs. When the Selection API selects only the second character (range [1,2)), PaintSelectedText calls ForEachGraphemeClusters with from=1, to=2. The skip loop runs three times for the three glyphs of U+0100, pushing cluster_start from 0 to 3. When processing the glyph for ‘B’, cluster_end is 2, and the function constructs StringView(text, 2, 1) on a text of length 2, which is one code unit past the end.

The same bug also exists in ShapeResult::ForEachGraphemeClusters in shape_result.cc.

Reproduce

Tested on commit d0f83d769eeed (macOS arm64).

Build:

autoninja -C ~/chromium/src/out/asan-release chrome

Run:

ASAN_OPTIONS=detect_odr_violation=0 ~/chromium/src/out/asan-release/Chromium.app/Contents/MacOS/Chromium \
  --no-sandbox --disable-gpu \
  --user-data-dir=/tmp/poc-$(date +%s) \
  issue_find021/poc.html

The renderer process crashes immediately on paint. No user interaction required.

[26598:93931928:0313/193828.315734:FATAL:third_party/blink/renderer/platform/wtf/text/string_view.h:401] Security DCHECK failed: length <= view.length() - offset.
0   libbase.dylib                       0x0000000102eacd88 base::debug::CollectStackTrace(base::span<void const*, 18446744073709551615ul, void const**>) + 28
1   libbase.dylib                       0x0000000102e62580 base::debug::StackTrace::StackTrace() + 80
2   libbase.dylib                       0x0000000102b40e3c logging::LogMessage::Flush() + 652
3   libbase.dylib                       0x0000000102b42aac logging::LogMessageFatal::~LogMessageFatal() + 12
4   libbase.dylib                       0x0000000102b42ad0 logging::LogMessageFatal::~LogMessageFatal() + 0
5   libblink_platform.dylib             0x000000014a6edbcc blink::ShapeResultView::ForEachGraphemeClusters(blink::StringView const&, float, unsigned int, unsigned int, unsigned int, void (*)(void*, unsigned int, float, unsigned int, float, blink::CanvasRotationInVertical), void*) const + 3756
6   libblink_platform.dylib             0x000000014a6d665c blink::ShapeResultBloberizer::FillTextEmphasisGlyphsNG::FillTextEmphasisGlyphsNG(blink::FontDescription const&, blink::StringView const&, unsigned int, unsigned int, blink::ShapeResultView const*, blink::GlyphData const&) + 1600
7   libblink_platform.dylib             0x000000014a5c494c blink::Font::DrawEmphasisMarks(cc::PaintCanvas*, blink::TextFragmentPaintInfo const&, blink::AtomicString const&, gfx::PointF const&, cc::PaintFlags const&) const + 560
8   libblink_platform.dylib             0x000000014a99f380 blink::GraphicsContext::DrawEmphasisMarks(blink::Font const&, blink::TextFragmentPaintInfo const&, blink::AtomicString const&, gfx::PointF const&, blink::AutoDarkMode const&) + 420
9   libblink_core.dylib                 0x00000001522f06c0 blink::TextPainter::Paint(blink::TextFragmentPaintInfo const&, blink::TextPaintStyle const&, int, blink::AutoDarkMode const&, blink::TextPainter::ShadowMode) + 2016
10  libblink_core.dylib                 0x00000001522f1afc blink::TextPainter::PaintSelectedText(blink::TextFragmentPaintInfo const&, unsigned int, unsigned int, blink::TextPaintStyle const&, blink::TextPaintStyle const&, blink::LineRelativeRect const&, int, blink::AutoDarkMode const&) + 1512
11  libblink_core.dylib                 0x0000000152116c10 blink::BoxFragmentPainter::PaintTextItem(blink::InlineCursor const&, blink::PaintInfo const&, blink::PhysicalFixedOffset<blink::FixedPoint<6u, int>> const&, blink::PhysicalFixedOffset<blink::FixedPoint<6u, int>> const&) + 916

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker