← All reports

[5] YARR RegularExpression out-of-bounds write with duplicate named capture groups

MediumJSC YARROOB

e536815

Rated Medium — the wrapper handed the byte-code interpreter a buffer shorter than the interpreter's own recorded requirement, so a pattern-chosen index writes past it. What keeps it out of the High band is that no in-tree caller with an attacker-influenced pattern string is established for this façade, and the container's over-allocation absorbs small overruns.

Regular-expression matching in JSC produces its results in an "offsets vector": a flat array of unsigneds where each capture group occupies two consecutive slots (start offset, end offset), with slot 0 and 1 reserved for the whole match. Two independent consumers drive the same engine — JSC::RegExp, which backs the JavaScript-visible RegExp object, and JSC::Yarr::RegularExpression, a simpler wrapper exported for callers outside JavaScriptCore. Both must hand the byte-code interpreter a vector at least as long as the byte code's own recorded requirement, because the interpreter indexes that vector directly by ids baked in at compile time.

The angle: a pattern with duplicate named capture groups drives the byte-code interpreter to write at indices past the end of the buffer the wrapper allocated — landing on match()'s stack frame or, for larger patterns, adjacent heap memory.

RegularExpression's offsets vector allocation size is incorrect: that formula was updated when named captures are added, but RegularExpression's computation was not updated correctly. This patch fixes it.

Source/JavaScriptCore/yarr/RegularExpression.cpp

int RegularExpression::match(StringView str, unsigned startFrom, int* matchLength) const
{
...
- int offsetVectorSize = (d->m_numSubpatterns + 1) * 2;
+ int offsetVectorSize = d->m_regExpByteCode->m_offsetsSize;
unsigned* offsetVector;
Vector<unsigned, 32> nonReturnedOvector;
 
nonReturnedOvector.grow(offsetVectorSize);
offsetVector = nonReturnedOvector.mutableSpan().data();
 
ASSERT(offsetVector);
for (unsigned j = 0, i = 0; i < d->m_numSubpatterns + 1; j += 2, i++)
offsetVector[j] = offsetNoMatch;
...
result = interpret(d->m_regExpByteCode.get(), str, startFrom, offsetVector);

Tools/TestWebKitAPI/Tests/JavaScriptCore/RegularExpression.cpp

+TEST(JavaScriptCore_RegularExpression, DuplicateNamedCaptureGroupSimple)
+{
+ RegularExpression re("(?<a>x)|(?<a>y)"_s, { JSC::Yarr::Flags::UnicodeSets });
+ EXPECT_TRUE(re.isValid());
+ int matchLength = 0;
+ EXPECT_EQ(0, re.match("x"_s, 0, &matchLength));
+ ...
+TEST(JavaScriptCore_RegularExpression, DuplicateNamedCaptureGroupMultiple)
+{
+ RegularExpression re("(?<a>x)|(?<a>y)|(?<b>x)|(?<b>y)|(?<c>x)|(?<c>y)"_s, { JSC::Yarr::Flags::UnicodeSets });
+ ...

One line of production code changes. In JSC::Yarr::RegularExpression::match(), the local offsetVectorSize — which sizes the Vector<unsigned, 32> nonReturnedOvector handed to Yarr::interpret() as the output vector — was computed with the wrapper's own formula (d->m_numSubpatterns + 1) * 2. The patch replaces that with the size the byte-code compiler itself recorded: d->m_regExpByteCode->m_offsetsSize.

Nothing else in match() changes. The nonReturnedOvector.grow(offsetVectorSize) call, the offsetNoMatch initialization loop (still bounded by m_numSubpatterns + 1), and the interpret() call are untouched. The remaining hunks are collateral: a new API test file with four TEST(JavaScriptCore_RegularExpression, DuplicateNamedCaptureGroup*) cases building RegularExpression objects over patterns with duplicate named capture groups under Flags::UnicodeSets, plus one-line registration in Tools/TestWebKitAPI/CMakeLists.txt. The tests are the trigger evidence: every one uses a pattern of the form (?<a>x)|(?<a>y), i.e. the same group name reused across alternatives.

A consumer re-deriving a buffer's required size with its own copy of a formula instead of reading the size the producer recorded.

The offsets vector. YARR's match output is a flat array of unsigneds; for each capture group the interpreter stores a start offset and an end offset in two consecutive slots, with slot 0/1 reserved for the whole match. offsetNoMatch is the sentinel written into slots for groups that did not participate. YarrPattern::m_numSubpatterns counts the capturing groups, so the classic ovector length is (numSubpatterns + 1) * 2.

Duplicate named capture groups. A language feature permitting the same group name to appear more than once in a pattern as long as the occurrences are in mutually exclusive alternatives, e.g. (?<a>x)|(?<a>y). YARR tracks these with a per-name id and addresses their slots via BytecodePattern::offsetForDuplicateNamedGroupId(id); these slots sit after the conventional start/end pairs. BytecodePattern::m_offsetsSize is the byte-code object's record of how many unsigned slots the interpreter expects.

Two consumers of one engine. JSC::RegExp (runtime/RegExp.cpp) sizes its m_ovector from offsetVectorBaseForNamedCaptures() plus m_rareData->m_numDuplicateNamedCaptureGroups. JSC::Yarr::RegularExpression (yarr/RegularExpression.cpp) is an independent, simpler wrapper — a thin façade over YarrPatternbyteCompile()interpret(), declared JS_EXPORT_PRIVATE so it is callable from outside JavaScriptCore — that allocates its own scratch vector per match() call.

Vector<T, N> inline capacity and growth policy. WTF's Vector template's second parameter is an inline capacity — up to N elements are stored in a buffer embedded in the Vector object itself, and only a request beyond N triggers a heap allocation. grow() routes through expandCapacity(), which reserves max(requested, max(16, capacity() + capacity() / 4 + 1)), so the reserved capacity after a heap growth is generally larger than the requested size. WTF's VectorTraits mark simple/POD element types as needsInitialization = false, so growing a Vector<unsigned> exposes elements whose contents are unspecified rather than zeroed. nonReturnedOvector is declared Vector<unsigned, 32> as a stack local of match().

interpret(). The YARR byte-code interpreter entry point; it takes the BytecodePattern, the subject string, a start offset, and a raw unsigned* output vector, and indexes that vector directly by the ids baked into the byte code.

The root cause is a duplicated size formula that only one of its two copies got updated. When support for duplicate named capture groups was added, the offsets vector grew an additional trailing region addressed via offsetForDuplicateNamedGroupId(id); the producer records the true total in m_offsetsSize, and RegExp::finishCreation() consumes it correctly. The wrapper's hand-rolled (m_numSubpatterns + 1) * 2 was never touched.

  Byte code expects (m_offsetsSize):
  [ whole ][ sub1 ][ sub2 ] ... [ dupA ][ dupB ]
  |<------ (numSubpatterns+1)*2 ------>|<--- unallocated --->|
                                        ^
                          offsetForDuplicateNamedGroupId(id)
                          writes here, past what grow() asked for

The interpreter touches those slots directly by index: ParenthesesDisjunctionContext's constructor performs subpatternAndGroupIdBackup[...] = output[m_pattern->offsetForDuplicateNamedGroupId(duplicateNamedGroupId)] (a read) followed by output[pattern->offsetForDuplicateNamedGroupId(duplicateNamedGroupId)] = 0 (a write), and restoreOutput() writes the saved value back. With the stale formula, every such index lands at or past (m_numSubpatterns + 1) * 2.

What memory actually gets clobbered is governed by the vector's reserved capacity, not by the size passed to grow(), and the two diverge in both directions. While the stale size stays at or below 32, the storage is the embedded inline buffer and its capacity is exactly 32: a touched index of 32 or greater — roughly fifteen subpatterns plus several duplicate named groups — writes past the embedded buffer onto match()'s surrounding stack frame. Once the stale size exceeds 32, grow() routes through expandCapacity(), and growing out of a 32-element inline buffer reserves at least 41 elements; an overrun of a few slots past a stale requested size just above 32 consequently still lands inside the vector's own heap allocation, in its uninitialized slack, not on adjacent heap objects. Reaching adjacent heap memory requires the highest touched index to exceed the actual reserved capacity, which for larger patterns tracks roughly 1.25× the stale size. So the quiet window is wider than a naive reading suggests on the heap path, and narrower than it looks on the inline path.

There is a residual detail worth noting after the fix: the buffer is now m_offsetsSize long while the explicit offsetNoMatch seeding loop still stops at m_numSubpatterns + 1, leaving the trailing duplicate-group slots unseeded by the wrapper. WTF's VectorTraits mark simple/POD types as not needing initialization, so grow() does not zero them — the correctness of that gap rests on the interpreter initializing those slots before reading them, which it does for at least the ParenthesesDisjunctionContext path but which is not established for every path through interpret().

Discovery reads as pattern auditing or variant analysis rather than fuzzing: the fix is a single stale-formula line and the added tests are hand-written API tests all using Flags::UnicodeSets with duplicate named capture groups, which is the signature of someone deliberately enumerating consumers of the offsets-vector layout after the feature landed. Fuzzing is a less likely finder precisely because the smallest patterns — including the ones in the new tests — keep every touched index inside the inline buffer, and the heap growth policy over-allocates beyond the requested size, so no sanitizer report appears until a pattern pushes the touched index past reserved capacity.

This vulnerability weakens memory safety inside whichever process compiles a regular expression through the RegularExpression façade. The invariant broken before the fix is the contract between byteCompile() and interpret(): the output vector must hold at least m_offsetsSize unsigneds, and the wrapper silently supplied fewer whenever the pattern contained duplicate named capture groups. An attacker who could steer a pattern string into this API would obtain an out-of-bounds write at a pattern-chosen index whenever that index exceeds the vector's reserved capacity — landing on match()'s stack frame while the storage is still the embedded 32-element inline buffer, or on adjacent heap memory once the touched index runs past the over-allocated reserved heap capacity. Either would be a bounded corruption foothold, normally chained with grooming or with stack-layout knowledge rather than used directly. No in-tree caller feeding an attacker-influenced pattern string into this façade is identified here, which is what keeps the practical severity below that of the equivalent bug in the JS-visible RegExp path.

Takeaway: when triaging any "small overflow of a Vector<T, N>", compute the container's reserved capacity, not the requested length — below inline capacity the bound is exactly N and an overrun escapes onto the enclosing stack frame, while above it expandCapacity()'s over-allocation quietly absorbs modest overruns and hides them from ASan.