CVE-2026-65340
Overview
Background
- YarrJIT
- JavaScriptCore’s JIT compiler for regular expressions (
Source/JavaScriptCore/yarr/YarrJIT.cpp). It emits machine code that walks the input string, maintaining an index register (m_regs.index) and backtracking across alternatives. - Non-BMP characters
- code points ≥ U+10000 encoded as two UTF-16 units (a surrogate pair). With the
/u(Unicode) flag YarrJIT must advanceindexby 2 units for one non-BMP code point. Two build flags gate this:YARR_JIT_UNICODE_EXPRESSIONSandYARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP. - `m_useFirstNonBMPCharacterOptimization` / `firstCharacterAdditionalReadSize`
- an optimization where the matcher pre-reads the first (non-BMP) character and tracks the extra units consumed, so
indexreflects the wider read. - `checkInput()`
- emits a bounds check that
indexhas not run past the available input before re-entering an alternative.
Root Cause Analysis
The bug is in the alternative-backtracking path of YarrGenerator in YarrJIT.cpp. When backtracking from one alternative to another whose m_minimumSize differs, the code adjusts index by delta and jumps to beginOp->m_reentry. Under the first-non-BMP-character optimization, the first character consumes firstCharacterAdditionalReadSize extra input units, but the old backtracking adjustment did not account for them and jumped unconditionally (m_jit.jump(beginOp->m_reentry)) without re-validating input. The patch adds advancedIndexForNonBMP: when the optimization is active it first does add32(firstCharacterAdditionalReadSize, index) to restore the correct position, and because that may now push index past the end of input, it re-enters via checkInput().linkTo(beginOp->m_reentry) (a bounds-checked re-entry) instead of a raw jump. Without this, index could be left too small (underflow) or run past input end (overflow), desynchronizing the matcher’s notion of position from the actual string length.
The consequence surfaces in createRegExpMatchesArray: with a corrupted index, result.end (subpatternResults[1]) can be less than result.start, so the computed match length end - start underflows to a huge unsigned value — or a match can be reported extending past subject.length. The patch hardens the consumer with RELEASE_ASSERT(result.end >= result.start), turning the previously silent inconsistency (a bogus, possibly out-of-bounds match range used to build the array) into a controlled crash. The committed tests yarr-jit-non-bmp-backtrack-index-overflow.js and ...-underflow.js both assert m.index + m[0].length <= subj.length, directly characterizing the primitive as an out-of-bounds match range rather than the advisory’s generic “crash”.
start/end pair where end < start or end > length.Attack Path
- Craft regex + subject
Attacker JS compiles a
/uregex whose alternatives have differing minimum sizes and whose first branch is a non-BMP literal, e.g./d\0e?|\u{10000}c/u, against a subject like"\u{10000}d". - Force JIT + backtrack
re.exec(subj)triggers the Yarr JIT path and drives backtracking between the mismatched-size alternatives under the non-BMP optimization. - Index desync
The backtracking adjustment leaves
m_regs.indextoo small or past input end; the matcher reports a subpattern range withend < start(underflow) orend > subj.length(overflow). - Bad array build
createRegExpMatchesArrayuses that range;end - startunderflows to a huge length, so the result array is constructed over an out-of-bounds span of the string buffer. - Primitive
Out-of-bounds read of adjacent heap when materializing the match substring/array (info leak); a far enough overflow crashes the renderer. The patched
RELEASE_ASSERTnow aborts here.
Impact Assessment
RegExp with the /u flag and attacker-chosen alternatives/subject. The diff substantiates an out-of-bounds match range — an OOB read when the matched substring/subpattern is extracted (potential heap information disclosure), and a crash when the range is far out of bounds. This is stronger than the advisory’s “unexpected Safari crash” label: the underflowed unsigned length (end - start) is an OOB primitive, though the committed fix only hard-asserts the invariant. Controlled OOB write / escalation is not shown by the diff (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifSource/JavaScriptCore/yarr/YarrJIT.cpp |
modified | |
ifJSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js |
modified |
Files Changed
JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.jsJSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.jsSource/JavaScriptCore/runtime/RegExpMatchesArray.hSource/JavaScriptCore/yarr/YarrJIT.cpp
Audit Directions
- Index adjustments vs. bounds checksAudit every other YarrJIT backtracking/re-entry site that mutates
m_regs.indexto confirm acheckInput()follows when the adjustment can push index past input end. - Non-BMP optimization accountingReview all uses of
m_useFirstNonBMPCharacterOptimization/firstCharacterAdditionalReadSizefor symmetric add/sub of the extra read size across entry, re-entry, and backtrack paths. - Consumers of subpatternResultsCheck other readers of
subpatternResults[](named groups,indices/dflag arrays,replace) for theend < start/end > lengthassumption now enforced only increateRegExpMatchesArray.
Patch
diff --git a/Source/JavaScriptCore/runtime/RegExpMatchesArray.h b/Source/JavaScriptCore/runtime/RegExpMatchesArray.h
index 4d5d2304e1d1..4d46a7f017f8 100644
--- a/Source/JavaScriptCore/runtime/RegExpMatchesArray.h
+++ b/Source/JavaScriptCore/runtime/RegExpMatchesArray.h
@@ -76,7 +76,8 @@ ALWAYS_INLINE JSArray* createRegExpMatchesArray(
result.start = position;
result.end = subpatternResults[1];
-
+ RELEASE_ASSERT(result.end >= result.start);
+
JSArray* array;
JSArray* indicesArray = nullptr;
diff --git a/Source/JavaScriptCore/yarr/YarrJIT.cpp b/Source/JavaScriptCore/yarr/YarrJIT.cpp
index d5dc7005cc4d..90ead5391420 100644
--- a/Source/JavaScriptCore/yarr/YarrJIT.cpp
+++ b/Source/JavaScriptCore/yarr/YarrJIT.cpp
@@ -3925,9 +3925,19 @@ class YarrGenerator final : public YarrJITInfo {
// already correctly incremented, if more than one then decrement as appropriate.
unsigned delta = alternative->m_minimumSize - beginOp->m_alternative->m_minimumSize;
ASSERT(delta);
+ bool advancedIndexForNonBMP = false;
+#if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP)
+ if (m_useFirstNonBMPCharacterOptimization) {
+ m_jit.add32(m_regs.firstCharacterAdditionalReadSize, m_regs.index);
+ advancedIndexForNonBMP = true;
+ }
+#endif
if (delta != 1)
m_jit.sub32(MacroAssembler::Imm32(delta - 1), m_regs.index);
- m_jit.jump(beginOp->m_reentry);
+ if (advancedIndexForNonBMP)
+ checkInput().linkTo(beginOp->m_reentry, &m_jit);
+ else
+ m_jit.jump(beginOp->m_reentry);
} else {
// If the first alternative has minimum size 0xFFFFFFFFu, then there cannot
// be sufficent input available to handle this, so just fall through.
diff --git a/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
new file mode 100644
index 000000000000..8657a02fb82c
--- /dev/null
+++ b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
@@ -0,0 +1,12 @@
+const re = /d\0e?|\u{10000}c/u;
+const subj = "\u{10000}d";
+const m = re.exec(subj);
+
+if (m !== null) {
+ throw new Error(
+ "expected null, got match=" + JSON.stringify(m[0]) +
+ " at index=" + m.index +
+ " (m.index + m[0].length = " + (m.index + m[0].length) +
+ " > subj.length = " + subj.length + ")"
+ );
+}
diff --git a/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js
new file mode 100644
index 000000000000..52fb0bbf4021
--- /dev/null
+++ b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js
@@ -0,0 +1,5 @@
+const re = /(?!\u{10000})a*|\u{10000}b/u;
+const subj = "\u{10000}cc";
+const m = re.exec(subj);
+if (m[0].length > subj.length)
+ throw new Error("m[0].length (" + m[0].length + ") > subj.length (" + subj.length + ")");
Regression Test / PoC
diff --git a/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
new file mode 100644
index 000000000000..8657a02fb82c
--- /dev/null
+++ b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
@@ -0,0 +1,12 @@
+const re = /d\0e?|\u{10000}c/u;
+const subj = "\u{10000}d";
+const m = re.exec(subj);
+
+if (m !== null) {
+ throw new Error(
+ "expected null, got match=" + JSON.stringify(m[0]) +
+ " at index=" + m.index +
+ " (m.index + m[0].length = " + (m.index + m[0].length) +
+ " > subj.length = " + subj.length + ")"
+ );
+}
diff --git a/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js
new file mode 100644
index 000000000000..52fb0bbf4021
--- /dev/null
+++ b/JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js
@@ -0,0 +1,5 @@
+const re = /(?!\u{10000})a*|\u{10000}b/u;
+const subj = "\u{10000}cc";
+const m = re.exec(subj);
+if (m[0].length > subj.length)
+ throw new Error("m[0].length (" + m[0].length + ") > subj.length (" + subj.length + ")");