Medium webkit OOB 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionThis issue was addressed through improved state management.
ComponentJSC
Bug ClassOOB
Tracker316996
Fix commit30b9a27b47e8 (webkit) +30/-2
CISA KEVNot listed
CreditedClaudio Bozzato and Francesco Benvenuto of Cisco Talos, Josef Korbel (Citadelo)
Disclosed2026-09-14

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 advance index by 2 units for one non-BMP code point. Two build flags gate this: YARR_JIT_UNICODE_EXPRESSIONS and YARR_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 index reflects the wider read.
`checkInput()`
emits a bounds check that index has 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”.

Key insight
Alternative-backtracking in the Yarr JIT failed to re-add the extra input units consumed by the first-non-BMP-character optimization and skipped the input bounds check, leaving the match index out of sync so the matches array was built from a start/end pair where end < start or end > length.

Attack Path

  1. Craft regex + subject Attacker JS compiles a /u regex 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".
  2. Force JIT + backtrack re.exec(subj) triggers the Yarr JIT path and drives backtracking between the mismatched-size alternatives under the non-BMP optimization.
  3. Index desync The backtracking adjustment leaves m_regs.index too small or past input end; the matcher reports a subpattern range with end < start (underflow) or end > subj.length (overflow).
  4. Bad array build createRegExpMatchesArray uses that range; end - start underflows to a huge length, so the result array is constructed over an out-of-bounds span of the string buffer.
  5. 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_ASSERT now aborts here.

Impact Assessment

Reachable from unprivileged web content in the renderer via any JS 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

FunctionChangeNotes
if
Source/JavaScriptCore/yarr/YarrJIT.cpp
modified
if
JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
modified

Files Changed

  • JSTests/stress/yarr-jit-non-bmp-backtrack-index-overflow.js
  • JSTests/stress/yarr-jit-non-bmp-backtrack-index-underflow.js
  • Source/JavaScriptCore/runtime/RegExpMatchesArray.h
  • Source/JavaScriptCore/yarr/YarrJIT.cpp

Audit Directions

  • Index adjustments vs. bounds checks
    Audit every other YarrJIT backtracking/re-entry site that mutates m_regs.index to confirm a checkInput() follows when the adjustment can push index past input end.
  • Non-BMP optimization accounting
    Review all uses of m_useFirstNonBMPCharacterOptimization / firstCharacterAdditionalReadSize for symmetric add/sub of the extra read size across entry, re-entry, and backtrack paths.
  • Consumers of subpatternResults
    Check other readers of subpatternResults[] (named groups, indices/d flag arrays, replace) for the end < start / end > length assumption now enforced only in createRegExpMatchesArray.
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 + ")");
Loading diff…

Regression Test / PoC

shipped with the fix
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 + ")");
Loading diff…