← All reports

[4] Invalid RegExp memoized and folded into NewRegExp

MediumJSC RegExp runtimeOOB

d5531cc

Rated Medium. Two defects meet here: an aborted constructor leaves an object in the weak cache with no offset vector at all, and strength reduction bakes that object into optimized code without asking whether it is valid. Reaching the out-of-bounds store requires a stack-depth-dependent parse failure to line up first.

A JavaScript regular expression is processed twice over its life — once when the pattern is parsed, and again when the engine decides to generate a matcher — with caching in between. In JSC that work is split between the RegExp object, which owns the capture count and the offset vector every match writes its start/end pairs into, and Yarr, the pattern parser and matcher generator. The DFG's strength reduction pass, a cleanup that rewrites nodes into cheaper equivalents when operands are known at compile time, can turn new RegExp(...) into a direct allocation around a compile-time-frozen RegExp, on the assumption that anything reachable in the cache is a valid, fully constructed object.

The angle: a pattern whose parse aborts on stack depth can be memoized in a half-constructed state and later recompiled into a matcher that writes capture offsets through a span the object never allocated.

Three changes. DFGStrengthReductionPhase::handleNode now consults regExp->isValid() before freezeStrong-ing a RegExp and rewriting an untyped new RegExp node into NewRegExp, so an object whose construction failed is no longer baked into optimized code as a constant. RegExpCache::lookupOrCreate now returns the freshly created RegExp early, before weakAdd(m_weakCache, key, Weak<RegExp>(regExp, this)), when !regExp->isValid() — a failed pattern is no longer memoized, so later lookups of the same key stop receiving the half-initialized object. Separately, compile, compileMatchOnly and byteCodeCompileIfNecessary now carry the capture metadata across from the freshly re-parsed Yarr::YarrPattern rather than moving only m_atom and m_specificPattern and guarding the capture count with a debug-only ASSERT(m_numSubpatterns == pattern.m_numSubpatterns) that release builds discard.

An object whose construction failed is memoized and later folded into optimized code, with its metadata left describing a pattern the compiled matcher no longer matches.

  matcher (freshly re-parsed pattern)      RegExp object (stale)
  ----------------------------------      ---------------------
  writes 2 * (P + 1) offsets  ─────────►  ovectorSpan()  size 0
  P = pattern.m_numSubpatterns            m_numSubpatterns = 0
                                                  |
  createRegExpMatchesArray*  ◄────────────────────┘
  reads numSubpatterns() = 0 pairs

Construction and the cache. RegExp::finishCreation parses the pattern once. On failure it takes the if (!isValid()) early return, setting m_state = ParseError; every line that derives the object's metadata — including m_ovector = FixedVector<int>(offsetVectorSize) — sits after that return. RegExpCache::lookupOrCreate inserts the resulting object into m_weakCache, keyed by (flags, pattern), regardless.

The offset vector. Every match writes start/end pairs into a per-RegExp offset vector, and createRegExpMatchesArray* reads them back using regExp->numSubpatterns(). A genuine zero-capture pattern still allocates offsetVectorBaseForNamedCaptures() = (0 + 1) * 2 = 2 entries; the abort path allocates none, which is strictly less than any validly constructed RegExp.

Hard versus soft Yarr errors. Yarr::ErrorCode distinguishes hard errors — permanent syntax failures — from soft ones. TooManyDisjunctions is documented in YarrErrorCode.h as "we ran out stack compiling" and is explicitly not hard, so whether a given pattern parses is a function of the remaining stack at parse time rather than of the pattern itself.

Retry on soft failure. RegExp::matchInline's throwError lambda calls reset() whenever !hasHardError(m_constructionErrorCode), restoring m_state = NotCompiled and clearing the error code, so the same object retries compilation on a later match.

Strength reduction. The pass runs on a concurrent compiler thread and rewrites nodes whose operands are known at compile time — including turning new RegExp into a NewRegExp node that simply allocates a RegExpObject around a frozen constant.

Two invariants were absent. The first is that an object whose construction failed must not be memoized or baked into optimized code as a constant. The interpreter path for new RegExp(...) raises a SyntaxError on an invalid pattern, but the folded NewRegExp node just allocates around the frozen constant — so the JIT tier returned an object where the baseline tier threw. The second is that capture metadata must track the pattern Yarr actually compiled.

The soft-error retry is what joins them. Because TooManyDisjunctions depends on stack depth rather than on the pattern text, the same object can fail to parse once and succeed later. Pre-fix, that retry could emit a matcher for the full capture set while the object's own m_numSubpatterns stayed at 0 and m_ovector remained the empty vector the aborted finishCreation never allocated.

The two sides of a match then work from two different counts, as the diagram above shows. The generated matcher writes 2 * (P + 1) offsets — where P is the capture count of the freshly re-parsed Yarr::YarrPattern — through the span returned by ovectorSpan(), a span that is empty in this state. createRegExpMatchesArray* reads back regExp->numSubpatterns() pairs, the stale 0. The ASSERT(ovector.size() >= static_cast<size_t>(offsetVectorSize())) in matchInline is vacuous at these call sites, because offsetVectorSize() simply returns m_ovector.size() and the ovector passed in is m_ovector.

Because finishCreation returns before m_ovector = FixedVector<int>(offsetVectorSize) ever runs, there is no backing allocation for those stores to land in — the realistic outcome is a fault on a near-null store, i.e. an attacker-triggerable renderer crash. A steerable write would require a match path that routes offsets through a caller-owned Vector<int> sized from offsetVectorSize() instead of through m_ovector; that is a projected route, not what this state exhibits.

This vulnerability weakens two boundaries at once: tier consistency, where the JIT produced an object the interpreter would have refused to construct, and the length contract between the matcher and the buffer it writes into. Establishing anything past a crash requires arranging the stack-depth-dependent parse failure first, which is what keeps this out of the higher severity bands.