03015890a6 REGRESSION (311049@main): Clamp the word-boundary search index to the context buffer length in isWordStartMatch()
Triage note: A match extending past a complex-context run could make the word-start check read beyond the trimmed buffer; std::min clamp is a genuine OOB-read fix.
Contents
The bug at a glance
This is an out-of-bounds read in WebCore’s text-search word-boundary logic (ICUSearcher), reachable from ordinary find-in-page / window.find over attacker-controlled page text containing complex-context (e.g. Thai) runs. The read is past the end of a trimmed context buffer inside the web (content) process, so the impact is information disclosure or a crash rather than direct corruption, matching a medium rating. It is a regression introduced by 311049@main, narrowing the affected-version window.
extractSubspanIncludingContextNeededForDictionaryBasedWordBreak can return a context window shorter than the original match, breaking the full-buffer invariant start + length <= buffer.size(). isWordStartMatch computed wordBreakSearchStart = adjustedStart + length against the trimmed span and handed that index to findNextWordFromIndex, which then reads beyond the trimmed string. The fix clamps the starting index with std::min against contextBuffer.size().
Root cause
WebCore performs at-word-start text matching for find operations through ICUSearcher. To honor dictionary-based word breaking for scripts without explicit word delimiters (Thai, Lao, Khmer, CJK), isWordStartMatch does not run the word-break iterator over the entire text buffer. Instead it first calls extractSubspanIncludingContextNeededForDictionaryBasedWordBreak(buffer, start), which returns a trimmed contextBuffer plus a contextOffset describing how far the window was shifted. The code then rebases the match start into that window: adjustedStart = start - contextOffset.
The invariant the original code implicitly relied on is that a match of (start, length) always fits inside its buffer: start + length <= buffer.size(). That holds for the full buffer, but not for the trimmed context window. extractSubspanIncludingContextNeededForDictionaryBasedWordBreak only keeps as much trailing text as dictionary context requires; when the trailing characters of the match do not need dictionary context, the returned contextBuffer can end before the match does. In that situation adjustedStart + length exceeds contextBuffer.size().
The buggy line computed size_t wordBreakSearchStart = adjustedStart + length and used it as the starting index for a backwards word-break scan: while (wordBreakSearchStart > adjustedStart) wordBreakSearchStart = findNextWordFromIndex(contextBuffer, wordBreakSearchStart, false). findNextWordFromIndex builds/queries a break iterator over contextBuffer and indexes at wordBreakSearchStart. With that index past the end of contextBuffer, the routine reads out of bounds – past the trimmed span into adjacent heap memory – which can disclose bytes through matching behavior or crash the content process.
The trigger, exercised by the added layout-test case, is a search string whose match begins inside a complex-context run (thaiWords[0]) but continues into plain ASCII (" the quick brown fox …") that does not require dictionary context. The Thai prefix forces the window to be trimmed to the Thai run, while length spans the full ASCII tail, so adjustedStart + length runs past the window end. The fix clamps: wordBreakSearchStart = std::min(adjustedStart + length, contextBuffer.size()). The patch notes the clamp never falls below adjustedStart because the trimmed span always includes start, so the loop still terminates correctly at adjustedStart and the word-start semantics are preserved.
Key code
Clamp of the word-boundary search index in isWordStartMatch (Source/WebCore/editing/ICUSearcher.cpp)
auto [contextBuffer, contextOffset] = extractSubspanIncludingContextNeededForDictionaryBasedWordBreak(buffer, start);
size_t adjustedStart = start - contextOffset;
// Clamp because the trimmed context window may be shorter than adjustedStart + length.
size_t wordBreakSearchStart = std::min(adjustedStart + length, contextBuffer.size());
while (wordBreakSearchStart > adjustedStart)
wordBreakSearchStart = findNextWordFromIndex(contextBuffer, wordBreakSearchStart, false /* backwards */);
return wordBreakSearchStart == adjustedStart;
Patch walkthrough
Source/WebCore/editing/ICUSearcher.cpp— In isWordStartMatch, the unchecked size_t wordBreakSearchStart = adjustedStart + length is replaced with std::min(adjustedStart + length, contextBuffer.size()), bounding the backwards word-break scan’s starting index to the trimmed context window. A comment records that the trimmed window may be shorter than adjustedStart + length. The following while loop and findNextWordFromIndex calls are unchanged; only the seed index is now in-bounds.LayoutTests/editing/text-iterator/findString.html— Adds an AtWordStarts test whose match starts within a Thai (complex-context) run but extends into plain ASCII that needs no dictionary context, forcing the trimmed-window path where the old code read past the buffer. Expected result is a match at 7,53 then no further match.LayoutTests/editing/text-iterator/findString-expected.txt— Records the expected output for the new case (match at 7,53, then no match), locking in correct behavior with the clamp in place.
Background
isWordStartMatch — Helper in ICUSearcher that decides whether a text match begins at a word boundary, used to implement the AtWordStarts find option. It rebases the match into a dictionary-context window and walks a word-break iterator backwards from the match end to confirm the start aligns with a word boundary.
extractSubspanIncludingContextNeededForDictionaryBasedWordBreak — Trims the search buffer to just the span needed to correctly break words in dictionary-based scripts, returning the trimmed contextBuffer and a contextOffset. Crucially it can return a span that ends before the match’s end when the trailing characters need no dictionary context, which is what breaks the start + length <= size invariant.
findNextWordFromIndex — Advances/retreats a word-break iterator over the provided buffer from a given index. It assumes the index lies within the buffer; seeding it with an index past contextBuffer.size() causes it to read out of bounds while positioning the iterator.
Complex-context (dictionary-based) scripts — Scripts such as Thai, Lao, Khmer, and CJK have no spaces between words, so word breaking relies on dictionary lookup and surrounding context. WebKit trims a minimal context window for performance, and it is precisely this trimming that can make a match extend beyond the analyzed span.
AtWordStarts find option — A find/search modifier requiring matches to begin at word starts. It routes matches through isWordStartMatch, making that function reachable from web-exposed search paths (find-in-page, window.find, editing text iterators) over page-controlled text.
Vulnerability window
- Regression introduced — 311049@main reworked the word-break context handling in ICUSearcher, introducing the trimmed-window path whose shorter buffer no longer satisfied the start + length invariant assumed by isWordStartMatch.
- Latent OOB — Any AtWordStarts match beginning in a complex-context run but extending into non-context-requiring characters could seed the word-break scan past the trimmed buffer end.
- Discovery — Tracked as bug 317083 / rdar://179591659 (with rdar://179438867 on the test), flagged as a regression, indicating internal detection likely via fuzzing of find over mixed-script text.
- Reproduction — A layout-test case searching for a Thai word followed by an English sentence within a matching haystack deterministically drives the trimmed-window OOB read.
- Fix — Committed as 315233@main: std::min clamps the search-start index to contextBuffer.size(), preserving loop termination since the window always includes start.
Proof of concept
The added AtWordStarts layout-test line. The haystack is ‘prefix <Thai word> the quick brown fox jumps over the lazy dog’ and the needle is ‘<Thai word> the quick brown fox jumps over the lazy dog’. The match starts inside the Thai run (offset 7) but extends through ASCII text that needs no dictionary context, so the context window is trimmed to the Thai run and adjustedStart + length overruns it. Expected match is 7,53. This is a crash/OOB-read trigger via find, not a memory-disclosure exploit.
// From LayoutTests/editing/text-iterator/findString.html (added case)
await testFindString("prefix " + thaiWords[0] + " the quick brown fox jumps over the lazy dog", thaiWords[0] + " the quick brown fox jumps over the lazy dog", ["AtWordStarts"], [[7, 53], []]);
Exploitation
- Craft page text — Place a complex-context (Thai/Lao/CJK) run immediately followed by ASCII text so a word starting in the run extends past where dictionary context is needed.
- Invoke find at word starts — Trigger an AtWordStarts search (find-in-page or window.find) for the run-plus-ASCII string so isWordStartMatch takes the trimmed-window path and seeds the scan past contextBuffer.size().
- Out-of-bounds read — findNextWordFromIndex indexes beyond the trimmed span into adjacent heap. The observable outcome is a content-process crash; any information disclosure would be indirect (through match/no-match behavior) and is not demonstrated.
- Note — This is an OOB read with no write primitive; realistic impact is a renderer crash or narrow side-channel, consistent with the medium rating.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash in ICUSearcher::isWordStartMatch / findNextWordFromIndex —
- ASan reads just past a trimmed span —
- Fuzzing find with mixed scripts —
Audit directions
- All users of the trimmed context window —
- Index arithmetic in ICUSearcher —
- findNextWordFromIndex contract —
- Regression 311049@main —