CVE-2025-0241
Overview
Background
- `Intl.Segmenter`
- a JavaScript builtin implemented in SpiderMonkey that splits text into graphemes, words, or sentences according to Unicode segmentation rules.
- Latin1 vs. TwoByte strings
- SpiderMonkey stores string characters either as 8-bit
JS::Latin1Charor 16-bitchar16_t, and the character width selects which break-iterator specialization is used. - `SegmentsObject` / `SegmentIteratorObject`
NativeObjectsubclasses that cache the segmented string’s characters in a fixed slot (STRING_CHARS_SLOT) and hold the underlying ICU4X break iterator.- `STRING_CHARS_SLOT`
- the reserved slot storing a tagged pointer to the string’s raw character buffer, whose tag records whether the buffer is Latin1 or TwoByte.
Root Cause Analysis
The segmentation code determined the character width by calling segments->getString()->hasLatin1Chars(), querying the live JSString object rather than the character buffer that was actually cached in the object’s STRING_CHARS_SLOT when segmentation began. Because a JSString’s representation can change out from under the segmenter (for example a rope being flattened, or a string being linearized/depended upon), the width reported by the string could diverge from the width of the buffer stored in the slot. When that happened, code such as DestroyBreakIterator, GraphemeBoundaries, WordBoundaries, SentenceBoundaries, and EnsureBreakIterator would pick the wrong BreakIterator specialization (e.g. BreakIteratorLatin1 for a two-byte buffer or vice versa), causing the iterator to read the cached buffer with the wrong stride and bounds and corrupting memory.
The fix adds hasLatin1StringChars(), which reads the width directly from the cached getStringChars() tag, so the width used to select the specialization always matches the buffer actually being indexed, restoring the invariant that the buffer’s stored type and its interpreted type agree.
JSString (getString()->hasLatin1Chars()) instead of from the immutable, already-captured character buffer stored in STRING_CHARS_SLOT; the fix routes every width check through hasLatin1StringChars(), which inspects getStringChars() and therefore can never disagree with the buffer being read.Attack Path
- Create a segmenter over a mutable string
Script constructs an
Intl.Segmenterand callssegment()on a string whose backing representation (e.g. a rope or dependent string) can later be changed to a different character width. - Trigger cached-buffer capture
Iterating or calling boundary methods stores the current character buffer and its width tag into
STRING_CHARS_SLOTon theSegmentsObject/SegmentIteratorObject. - Cause a representation change
Further JavaScript operations flatten or otherwise convert the underlying
JSStringso thatgetString()->hasLatin1Chars()no longer matches the width of the buffer already cached in the slot. - Force a mismatched break iterator
A subsequent
GraphemeBoundaries/WordBoundaries/SentenceBoundariesorEnsureBreakIteratorcall reads the stale width from the string and selects the wrongBreakIteratorspecialization for the cached buffer. - Corrupt memory The mis-specialized iterator indexes the buffer with the wrong element size and bounds, reading or writing out of range and producing a potentially exploitable crash.
Impact Assessment
Intl.Segmenter on specially crafted text whose backing string representation changes after the segmenter caches its buffer, with no elevated privileges needed.Files Changed
js/src/builtin/intl/Segmenter.cppjs/src/builtin/intl/Segmenter.h
Audit Directions
- Cached-buffer vs. live-object divergenceLook for code that snapshots a buffer or width into a slot but later re-queries the mutable source object (
getString(), rope/dependent strings) for a property that must stay consistent with the snapshot. - Width-selected specializationsAudit every site that picks a Latin1/TwoByte or element-size-dependent code path (
hasLatin1Chars, template specialization onJS::Latin1Charvs.char16_t) to confirm the width source is the same buffer that is subsequently indexed. - ICU4X/`Intl` iterator lifetimesReview
Intl.Segmenterand relatedIntliterator objects for other places where a break iterator or cached pointer can outlive or diverge from the string representation it was built against.
Patch
diff --git a/js/src/builtin/intl/Segmenter.cpp b/js/src/builtin/intl/Segmenter.cpp
index 437a2f1a5d6..ab1f09a87be 100644
--- a/js/src/builtin/intl/Segmenter.cpp
+++ b/js/src/builtin/intl/Segmenter.cpp
@@ -574,7 +574,7 @@ static void DestroyBreakIterator(const T* segments) {
void* brk = segments->getBreakIterator();
MOZ_ASSERT(brk);
- bool isLatin1 = segments->getString()->hasLatin1Chars();
+ bool isLatin1 = segments->hasLatin1StringChars();
switch (segments->getGranularity()) {
case SegmenterGranularity::Grapheme: {
@@ -660,7 +660,7 @@ static Boundaries FindBoundaryFrom(Handle<T*> segments, int32_t index) {
template <typename T>
static Boundaries GraphemeBoundaries(Handle<T*> segments, int32_t index) {
#if defined(MOZ_ICU4X)
- if (segments->getString()->hasLatin1Chars()) {
+ if (segments->hasLatin1StringChars()) {
return FindBoundaryFrom<GraphemeClusterSegmenter::BreakIteratorLatin1>(
segments, index);
}
@@ -674,7 +674,7 @@ static Boundaries GraphemeBoundaries(Handle<T*> segments, int32_t index) {
template <typename T>
static Boundaries WordBoundaries(Handle<T*> segments, int32_t index) {
#if defined(MOZ_ICU4X)
- if (segments->getString()->hasLatin1Chars()) {
+ if (segments->hasLatin1StringChars()) {
return FindBoundaryFrom<WordSegmenter::BreakIteratorLatin1>(segments,
index);
}
@@ -687,7 +687,7 @@ static Boundaries WordBoundaries(Handle<T*> segments, int32_t index) {
template <typename T>
static Boundaries SentenceBoundaries(Handle<T*> segments, int32_t index) {
#if defined(MOZ_ICU4X)
- if (segments->getString()->hasLatin1Chars()) {
+ if (segments->hasLatin1StringChars()) {
return FindBoundaryFrom<SentenceSegmenter::BreakIteratorLatin1>(segments,
index);
}
@@ -782,7 +782,7 @@ static bool EnsureBreakIterator(JSContext* cx, Handle<T*> segments,
}
#if defined(MOZ_ICU4X)
- bool isLatin1 = segments->getString()->hasLatin1Chars();
+ bool isLatin1 = segments->hasLatin1StringChars();
// Create a new break iterator based on the granularity and character type.
void* brk;
diff --git a/js/src/builtin/intl/Segmenter.h b/js/src/builtin/intl/Segmenter.h
index 3b204578b45..2b75b864882 100644
--- a/js/src/builtin/intl/Segmenter.h
+++ b/js/src/builtin/intl/Segmenter.h
@@ -198,6 +198,11 @@ class SegmentsObject : public NativeObject {
setFixedSlot(STRING_CHARS_SLOT, PrivateValue(chars.tagged()));
}
+ bool hasLatin1StringChars() const {
+ MOZ_ASSERT(hasStringChars());
+ return getStringChars().has<JS::Latin1Char>();
+ }
+
int32_t getIndex() const {
const auto& slot = getFixedSlot(INDEX_SLOT);
if (slot.isUndefined()) {
@@ -295,6 +300,11 @@ class SegmentIteratorObject : public NativeObject {
setFixedSlot(STRING_CHARS_SLOT, PrivateValue(chars.tagged()));
}
+ bool hasLatin1StringChars() const {
+ MOZ_ASSERT(hasStringChars());
+ return getStringChars().has<JS::Latin1Char>();
+ }
+
int32_t getIndex() const {
const auto& slot = getFixedSlot(INDEX_SLOT);
if (slot.isUndefined()) {