CVE-2026-28904
Overview
Impact:
Description: Processing maliciously crafted web content may lead to an unexpected process crash
Researchers: Luka Rački
Key insight. YARR accumulated the regexp backtracking call-frame size in a plain unsigned that could overflow, producing an undersized frame the matcher then over-indexed; computing it with CheckedUint32 and rejecting overflow (FrameTooLarge) restores safety.
Patch Details
diff --git a/JSTests/stress/regexp-alternative-heavy.js b/JSTests/stress/regexp-alternative-heavy.js
new file mode 100644
index 000000000000..67fafb77ac15
--- /dev/null
+++ b/JSTests/stress/regexp-alternative-heavy.js
@@ -0,0 +1,41 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+for (const n of [1000]) {
+ tryCompileAndRun(
+ `Approach C: ${n} alternation groups (a|b)*`,
+ () => '(a|b)*'.repeat(n),
+ 'ab'.repeat(50)
+ );
+}
diff --git a/JSTests/stress/regexp-bol-optimize-out-of-stack.js b/JSTests/stress/regexp-bol-optimize-out-of-stack.js
index 7b99447a6b32..91ee1390ed5b 100644
--- a/JSTests/stress/regexp-bol-optimize-out-of-stack.js
+++ b/JSTests/stress/regexp-bol-optimize-out-of-stack.js
@@ -4,7 +4,7 @@
arrayLength = typeof(arrayLength) === 'undefined' ? 50000 : arrayLength;
-let expectedException = "SyntaxError: Invalid regular expression: regular expression too large";
+let expectedException = "SyntaxError: Invalid regular expression: too many captures";
function test()
{
diff --git a/JSTests/stress/regexp-combined-large.js b/JSTests/stress/regexp-combined-large.js
new file mode 100644
index 000000000000..605736beddc1
--- /dev/null
+++ b/JSTests/stress/regexp-combined-large.js
@@ -0,0 +1,48 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+const LARGE_N = 12_500_000;
+tryCompileAndRun(
+ `Approach F: ${LARGE_N} flat groups (maximum feasible)`,
+ () => {
+ // Build in chunks to avoid single large string concat
+ let parts = [];
+ const chunk = '(a)*'.repeat(10000); // 40KB per chunk
+ for (let i = 0; i < LARGE_N / 10000; i++) {
+ parts.push(chunk);
+ }
+ return parts.join('');
+ },
+ 'a'
+);
diff --git a/JSTests/stress/regexp-deep-nested.js b/JSTests/stress/regexp-deep-nested.js
new file mode 100644
index 000000000000..d6a80563d9ee
--- /dev/null
+++ b/JSTests/stress/regexp-deep-nested.js
@@ -0,0 +1,41 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+for (const depth of [100, 500, 1000, 5000, 10000, 50000]) {
+ tryCompileAndRun(
+ `Approach B: nested depth ${depth} — ((((a)*)*...)*)*`,
+ () => '('.repeat(depth) + 'a' + ')*'.repeat(depth),
+ 'a'.repeat(20)
+ );
+}
diff --git a/JSTests/stress/regexp-heavy-mixed.js b/JSTests/stress/regexp-heavy-mixed.js
new file mode 100644
index 000000000000..71bc1776c1f6
--- /dev/null
+++ b/JSTests/stress/regexp-heavy-mixed.js
@@ -0,0 +1,46 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28904 |
| Bugzilla | 309601 |
| Component | JSC |
| Bug Class | IntegerOverflow |
| Severity | medium |
| Commit | 7663d811d06c3255… |
| Advisory | Apple Advisory |
Terminology
- YARR — JavaScriptCore’s regular-expression engine that compiles patterns to bytecode/JIT with a backtracking model.
- Backtracking call frame — Per-match stack space YARR reserves to store backtracking state; its size is computed from the pattern structure.
- CheckedUint32 / Checked<unsigned> — WTF integer wrappers that record overflow so it can be detected instead of silently wrapping.
- FrameTooLarge (new) — The error code the fix returns to reject a pattern whose backtracking frame size overflows 32 bits.
Root Cause Analysis
During YARR (JavaScriptCore’s regexp engine) pattern compilation, setupAlternativeOffsets/setupDisjunctionOffsets compute the size of the backtracking call frame a regexp needs, accumulating per-construct stack space (e.g. YarrStackSpaceForBackTrackInfoParentheticalAssertion and similar) as a plain unsigned. For a sufficiently large or deeply nested pattern, this running total can exceed 2^32 and wrap around, so the computed newCallFrameSize is far smaller than the space the compiled matcher will actually use. The compiler then sizes the backtracking frame from the wrapped-small value while the matcher indexes it as if it were the true (large) size, an out-of-bounds access of the backtracking stack frame — memory corruption / crash. The fix changes the call-frame-size accumulators to CheckedUint32 (currentCallFrameSize, initialCallFrameSize, perAlternativeInitial, currentAlternativeCallFrameSize, ignoredCallFrameSize) and, at every accumulation point, returns the new ErrorCode::FrameTooLarge when hasOverflowed() is true, so an over-large pattern is rejected at compile time instead of producing an undersized frame; a TooManyCaptures error code is likewise added. Complementary hardening in AssemblerBuffer.h switches capacity growth to Checked<unsigned> and compares indices as 64-bit to avoid analogous overflow in the code buffer. The restored invariant is that the regexp backtracking frame size is computed with overflow checking and any pattern whose frame would overflow 32 bits is rejected rather than compiled with a truncated size.
Attack Path
1. Craft an over-large regexp
Construct a pattern (deeply nested groups/assertions/alternations, or very many captures) whose accumulated backtracking call-frame size exceeds 2^32.
2. Compile it
Have web content compile the RegExp (new RegExp / literal), driving setupAlternativeOffsets/setupDisjunctionOffsets to accumulate the frame size in a plain unsigned.
3. Overflow the frame size
The unsigned accumulation wraps to a small value, so newCallFrameSize is far smaller than the matcher actually needs.
4. Out-of-bounds backtracking
Executing a match allocates the undersized frame but indexes it at the true offsets, reading/writing out of bounds of the backtracking stack — corruption or crash in WebContent.
Impact Assessment
An integer-overflow in regexp backtracking-frame sizing that yields an undersized frame indexed at full offsets — an out-of-bounds read/write on the backtracking stack in the WebContent process. It is reachable directly from a crafted RegExp in web content; the demonstrated effect is a crash, but a controlled frame-size mismatch is a plausible corruption primitive. Rejecting over-large patterns removes it. Rated medium.
Audit Directions
Other unchecked size accumulation in YARR
grep yarr/ for unsigned running totals of offsets/sizes (input positions, frame slots, capture counts) added in loops without Checked/hasOverflowed().
Consumers of call-frame size
Audit the JIT/interpreter backends that allocate and index the backtracking frame to ensure they use the same checked size and honor FrameTooLarge.
Assembler buffer growth
Review other AssemblerBuffer/CodeBuffer growth and index arithmetic for 32-bit overflow like the ones hardened here.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
YarrPatternConstructor::setupAlternativeOffsets |
Source/JavaScriptCore/yarr/YarrPattern.cpp |
modified | currentCallFrameSize/newCallFrameSize become CheckedUint32; every accumulation checks hasOverflowed() and returns ErrorCode::FrameTooLarge. |
YarrPatternConstructor::setupDisjunctionOffsets |
Source/JavaScriptCore/yarr/YarrPattern.cpp |
modified | initialCallFrameSize/callFrameSize/perAlternativeInitial/currentAlternativeCallFrameSize become CheckedUint32 with overflow checks before use of .value(). |
errorMessage / errorToThrow (+ ErrorCode) |
Source/JavaScriptCore/yarr/YarrErrorCode.cpp |
modified | Adds FrameTooLarge and TooManyCaptures error codes and their thrown messages so over-large patterns fail cleanly. |
AssemblerBuffer grow/putIntegral |
Source/JavaScriptCore/assembler/AssemblerBuffer.h |
modified | Uses Checked<unsigned> for capacity growth and 64-bit index comparisons to prevent analogous integer overflow in the code buffer. |
Files Changed
Source Files
Source/JavaScriptCore/assembler/AssemblerBuffer.hSource/JavaScriptCore/yarr/YarrErrorCode.cppSource/JavaScriptCore/yarr/YarrErrorCode.hSource/JavaScriptCore/yarr/YarrParser.hSource/JavaScriptCore/yarr/YarrPattern.cppSource/JavaScriptCore/yarr/YarrSyntaxChecker.cpp
Test Files
JSTests/stress/regexp-alternative-heavy.jsJSTests/stress/regexp-bol-optimize-out-of-stack.jsJSTests/stress/regexp-combined-large.jsJSTests/stress/regexp-deep-nested.jsJSTests/stress/regexp-heavy-mixed.jsJSTests/stress/regexp-lookahead-heavy.jsJSTests/stress/stack-overflow-regexp.jsLayoutTests/js/script-tests/stack-overflow-regexp.jsLayoutTests/js/stack-overflow-regexp-expected.txt
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/stress/regexp-alternative-heavy.js b/JSTests/stress/regexp-alternative-heavy.js
new file mode 100644
index 000000000000..67fafb77ac15
--- /dev/null
+++ b/JSTests/stress/regexp-alternative-heavy.js
@@ -0,0 +1,41 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+for (const n of [1000]) {
+ tryCompileAndRun(
+ `Approach C: ${n} alternation groups (a|b)*`,
+ () => '(a|b)*'.repeat(n),
+ 'ab'.repeat(50)
+ );
+}
diff --git a/JSTests/stress/regexp-bol-optimize-out-of-stack.js b/JSTests/stress/regexp-bol-optimize-out-of-stack.js
index 7b99447a6b32..91ee1390ed5b 100644
--- a/JSTests/stress/regexp-bol-optimize-out-of-stack.js
+++ b/JSTests/stress/regexp-bol-optimize-out-of-stack.js
@@ -4,7 +4,7 @@
arrayLength = typeof(arrayLength) === 'undefined' ? 50000 : arrayLength;
-let expectedException = "SyntaxError: Invalid regular expression: regular expression too large";
+let expectedException = "SyntaxError: Invalid regular expression: too many captures";
function test()
{
diff --git a/JSTests/stress/regexp-combined-large.js b/JSTests/stress/regexp-combined-large.js
new file mode 100644
index 000000000000..605736beddc1
--- /dev/null
+++ b/JSTests/stress/regexp-combined-large.js
@@ -0,0 +1,48 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+const LARGE_N = 12_500_000;
+tryCompileAndRun(
+ `Approach F: ${LARGE_N} flat groups (maximum feasible)`,
+ () => {
+ // Build in chunks to avoid single large string concat
+ let parts = [];
+ const chunk = '(a)*'.repeat(10000); // 40KB per chunk
+ for (let i = 0; i < LARGE_N / 10000; i++) {
+ parts.push(chunk);
+ }
+ return parts.join('');
+ },
+ 'a'
+);
diff --git a/JSTests/stress/regexp-deep-nested.js b/JSTests/stress/regexp-deep-nested.js
new file mode 100644
index 000000000000..d6a80563d9ee
--- /dev/null
+++ b/JSTests/stress/regexp-deep-nested.js
@@ -0,0 +1,41 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+for (const depth of [100, 500, 1000, 5000, 10000, 50000]) {
+ tryCompileAndRun(
+ `Approach B: nested depth ${depth} — ((((a)*)*...)*)*`,
+ () => '('.repeat(depth) + 'a' + ')*'.repeat(depth),
+ 'a'.repeat(20)
+ );
+}
diff --git a/JSTests/stress/regexp-heavy-mixed.js b/JSTests/stress/regexp-heavy-mixed.js
new file mode 100644
index 000000000000..71bc1776c1f6
--- /dev/null
+++ b/JSTests/stress/regexp-heavy-mixed.js
@@ -0,0 +1,46 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+tryCompileAndRun(
+ `Approach E: mixed named/quantified/lookahead (10k groups)`,
+ () => {
+ // Each unit: (?<x>a)* — named capture with quantifier
+ // Duplicate named captures in alternation arms force numDuplicateNamedCaptures > 0
+ const unit = '(?<g>a(?<h>b)*)';
+ const repeated = unit.repeat(5000);
+ // Wrap in alternation to trigger duplicate-named-capture counting
+ return `(?:${repeated}|${unit.repeat(100)})+`;
+ },
+ 'ab'.repeat(30)
+);
diff --git a/JSTests/stress/regexp-lookahead-heavy.js b/JSTests/stress/regexp-lookahead-heavy.js
new file mode 100644
index 000000000000..820a5e2917ff
--- /dev/null
+++ b/JSTests/stress/regexp-lookahead-heavy.js
@@ -0,0 +1,41 @@
+//@ runDefault
+
+function tryCompileAndRun(label, buildPattern, testInput) {
+ // print(`\n[*] ${label}`);
+ let pattern;
+ try {
+ pattern = buildPattern();
+ // print(` Pattern length: ${pattern.length} chars`);
+ } catch(e) {
+ print(` BUILD ERROR: ${e.message}`);
+ return;
+ }
+
+ let re;
+ try {
+ re = new RegExp(pattern, 'g');
+ // print(` RegExp constructed OK`);
+ } catch(e) {
+ print(` REGEXP CONSTRUCTION ERROR (likely limit hit): ${e.message}`);
+ return;
+ }
+
+ // Warm the JIT — JSC typically tiered-compiles after ~6 invocations
+ try {
+ for (let i = 0; i < 8; i++) {
+ re.lastIndex = 0;
+ re.exec(testInput);
+ }
+ // print(` exec() completed without crash — check for SP corruption artifacts`);
+ } catch(e) {
+ print(` RUNTIME ERROR: ${e.message}`);
+ }
+}
+
+for (const n of [500, 5000, 20000]) {
+ tryCompileAndRun(
+ `Approach D: ${n} lookaheads (?=(a))*`,
+ () => '(?=(a))'.repeat(n) + 'a*',
+ 'a'.repeat(50)
+ );
+}
diff --git a/JSTests/stress/stack-overflow-regexp.js b/JSTests/stress/stack-overflow-regexp.js
index c8bccff65891..7ef0d3117820 100644
--- a/JSTests/stress/stack-overflow-regexp.js
+++ b/JSTests/stress/stack-overflow-regexp.js
@@ -7,9 +7,9 @@ function testDeepNesting() {
try {
new RegExp(Array(520000).join("(") + "a" + Array(520000).join(")"));
} catch (e) {
- if (e instanceof RangeError)
+ if (e instanceof SyntaxError || e instanceof RangeError)
return;
- throw new Error("Expected RangeError, got " + e);
+ throw new Error("Expected SyntaxError or RangeError, got " + e);
}
// With certain stack configurations the pattern may fit; that's OK.
}
diff --git a/LayoutTests/js/script-tests/stack-overflow-regexp.js b/LayoutTests/js/script-tests/stack-overflow-regexp.js
new file mode 100644
index 000000000000..730e82177440
--- /dev/null
+++ b/LayoutTests/js/script-tests/stack-overflow-regexp.js
@@ -0,0 +1,31 @@
+//@ skip if $memoryLimited
+// &&&&
+description('Test that we do not overflow the stack while handling regular expressions');
+
+// Base case.
+shouldThrow('new RegExp(Array(500000).join("(") + "a" + Array(500000).join(")"))', '"SyntaxError: Invalid regular expression: too many captures"');
+
+{ // Verify that a deep JS stack does not help avoiding the error.
+ function recursiveCall(depth) {
+ if (!(depth % 10)) {
+ debug("Creating RegExp at depth " + depth);
+ shouldThrow('new RegExp(Array(500000).join("(") + "a" + Array(500000).join(")"))', '"SyntaxError: Invalid regular expression: too many captures"');
+ }
+ if (depth < 100) {
+ recursiveCall(depth + 1);
+ }
+ }
+ recursiveCall(0);
... (truncated)