dcf25ed3c9 [JSC] Make RegExp::byteCodeCompileIfNecessary threadsafe
Triage note: Thread-safety fix for concurrent regexp bytecode compilation.
Contents
The bug at a glance
This is a data race on JSC’s regexp bytecode-compilation path between the mutator (VM thread) and the JIT compiler thread, both of which can call into RegExp matching. Data races on shared JIT/runtime state are memory-safety bugs: an unsynchronized compile of m_regExpBytecode racing with a read can produce a torn or half-initialized BytecodePattern that the interpreter then executes. The commit acknowledges the race is only manifestable with artificial sleeps, so it is a hard-to-hit but real corruption primitive rather than a trivially weaponizable one, placing it firmly at high but not critical.
RegExp::matchConcurrently is invoked from the compiler thread and is supposed to never trigger compilation, only reuse existing JIT code. But a RegExp can legitimately have JIT code yet no bytecode, and when the JIT punts (JITCodeFailure) the fallback called byteCodeCompileIfNecessary() with no lock — so the compiler thread could race the mutator into compiling m_regExpBytecode. The fix locks byteCodeCompileIfNecessary with the cell lock and restricts actual compilation to the VM/mutator thread, with the compiler thread bailing out when no bytecode exists.
Root cause
JSC evaluates regular expressions through two engines: Yarr’s JIT-compiled matcher and a bytecode interpreter (Yarr::interpret over a Yarr::BytecodePattern held in RegExp::m_regExpBytecode). RegExp::matchInline first tries the JIT code; if the JIT reports Yarr::JSRegExpResult::JITCodeFailure (the compiled matcher cannot handle this input configuration, e.g. very deep backtracking), it punts to the bytecode interpreter, lazily building the bytecode via byteCodeCompileIfNecessary().
The threading invariant is that only the mutator (the JS VM thread) may mutate a RegExp’s compiled state; the DFG/FTL compiler threads may read existing compiled artifacts to constant-fold or inline regexp operations concurrently, through RegExp::matchConcurrently, but must never themselves compile. The problem: this invariant was only half-enforced. matchConcurrently correctly bailed if no JIT code existed, but a RegExp can have JIT code and still lack bytecode (bytecode is only built on a JIT punt). When the compiler thread ran matchInline, hit JITCodeFailure, and fell into the fallback, the pre-patch code unconditionally called byteCodeCompileIfNecessary(&vm) — with no lock. If the mutator was simultaneously compiling the same RegExp’s bytecode, two threads would race to allocate and publish m_regExpBytecode, and the interpreter could then read a partially constructed BytecodePattern, or the unique_ptr itself could be torn.
The fix has two parts. First, byteCodeCompileIfNecessary now takes Locker locker { cellLock() }; at entry, before the if (m_regExpBytecode) return; fast path, so the check-then-compile-then-publish sequence is atomic with respect to any other holder of the cell lock. Second, in both matchInline overloads (the int and the MatchResult variants), the punt-to-interpreter path is now guarded by if constexpr (matchFrom == Yarr::MatchFrom::VMThread): only the VM/mutator thread calls byteCodeCompileIfNecessary and may throw a parse error. After that, both threads execute if (!m_regExpBytecode) return -1; (or MatchResult::failed()) — so the compiler thread, which never compiles, simply bails out of matching when bytecode is absent, returning a benign no-match rather than racing to build it.
The commit note adds an important subtlety: the m_regExpBytecode read inside matchInline itself does not need to take the cell lock at that point, because matchConcurrently — the caller on the compiler thread — already holds the cell lock, and on the VM thread the compile just performed is naturally ordered. So the lock is placed at the one true mutation choke-point (byteCodeCompileIfNecessary) and the thread-role check keeps the compiler thread out of the mutation path entirely.
Key code
RegExpInlines.h — only the VM thread may compile bytecode; the compiler thread bails
if (result == static_cast<int>(Yarr::JSRegExpResult::JITCodeFailure)) {
// Punt to the bytecode interpreter. Only the mutator may compile bytecode; the compiler
// thread must use the bytecode that already exists, and bails out if there is no
// bytecode.
if constexpr (matchFrom == Yarr::MatchFrom::VMThread) {
byteCodeCompileIfNecessary(&vm);
if (m_state == ParseError)
return throwError();
}
if (!m_regExpBytecode)
return -1;
{
Yarr::MatchingContextHolder regExpContext(vm, this, matchFrom);
result = Yarr::interpret(m_regExpBytecode.get(), s, startOffset, reinterpret_cast<unsigned*>(offsetVector));
}
Patch walkthrough
Source/JavaScriptCore/runtime/RegExp.cpp— byteCodeCompileIfNecessary(VM*) now acquiresLocker locker { cellLock() };as its first statement, ahead of theif (m_regExpBytecode) return;early-out. This serializes the check-and-build of m_regExpBytecode so two threads cannot both allocate and publish the BytecodePattern.Source/JavaScriptCore/runtime/RegExpInlines.h— Both RegExp::matchInline overloads change the JITCodeFailure fallback: the byteCodeCompileIfNecessary call and the ParseError throw are now wrapped inif constexpr (matchFrom == Yarr::MatchFrom::VMThread), so only the mutator compiles. Immediately after,if (!m_regExpBytecode) return -1;(int overload) /return MatchResult::failed();(MatchResult overload) makes the compiler thread bail cleanly when no bytecode exists instead of compiling it.
Background
Yarr JIT vs bytecode interpreter — JSC’s regexp engine (Yarr) has two backends: a native JIT and a bytecode interpreter (Yarr::interpret). The JIT is tried first; certain patterns/inputs it cannot handle return JSRegExpResult::JITCodeFailure, forcing a fallback to the interpreter, which requires a compiled Yarr::BytecodePattern stored in RegExp::m_regExpBytecode.
Lazy bytecode compilation — A RegExp does not always have bytecode: JIT code and bytecode are compiled independently, and bytecode is only built lazily when the JIT punts. So the state (JIT code present, bytecode absent) is reachable, and it is exactly the window in which the fallback path needs to compile bytecode on demand.
Mutator vs compiler thread in JSC — The mutator is the thread running JavaScript and owning heap mutation; the DFG/FTL compiler threads run concurrently and may read runtime objects (via concurrent-safe paths) to fold constants and inline. The invariant is that compiler threads observe but do not mutate compiled runtime state; violating it races with the mutator.
matchConcurrently and MatchFrom — RegExp::matchConcurrently is the entry used by compiler threads; it is templated on Yarr::MatchFrom (VMThread vs a compiler context). It is meant to only reuse pre-existing JIT code. The MatchFrom template parameter is what the fix keys the if constexpr on to distinguish mutator from compiler thread at compile time.
cellLock() as the synchronization primitive — Every JSCell exposes a per-cell spin lock, cellLock(), used to guard fine-grained per-object state against concurrent access from compiler threads and the GC. Taking it in byteCodeCompileIfNecessary makes the bytecode check-and-build atomic; the comment notes matchInline’s later read needs no additional lock because matchConcurrently already holds it.
Vulnerability window
- Concurrent JIT tiering — The DFG/FTL compiler thread constant-folds/inlines a regexp op and calls RegExp::matchConcurrently, which finds existing JIT code and proceeds.
- JIT punt on compiler thread — The JIT returns JITCodeFailure for the given input; matchInline enters the fallback. The RegExp has JIT code but no bytecode yet.
- Unsynchronized compile (pre-patch) — The compiler thread calls byteCodeCompileIfNecessary with no lock, while the mutator may be doing the same for the same RegExp.
- Race window — Two threads allocate/publish m_regExpBytecode concurrently; a torn unique_ptr or a half-initialized BytecodePattern can be observed.
- Interpreter executes bad pattern — Yarr::interpret runs over the racily built pattern, risking memory corruption during matching.
- Fix — cellLock() serializes compilation, and the
if constexpr (VMThread)guard plusif (!m_regExpBytecode) return failedkeeps the compiler thread out of the mutation path entirely.
Triggering
No test was added; the commit states manifesting the race requires artificially inserting sleeps into the mutator and compiler threads to widen the window. Conceptual trigger: run a hot regexp whose input configuration forces Yarr JITCodeFailure (deep backtracking) so bytecode is compiled lazily, while a tight loop keeps the same RegExp hot enough to be picked up by a DFG/FTL concurrent compile — the compiler thread then races the mutator in byteCodeCompileIfNecessary. Reliably observing corruption needs instrumented builds; on production builds the window is extremely narrow.
Exploitation
- Race setup — Keep a RegExp both hot (so it gets concurrently JIT-tier-compiled) and in the JIT-code-without-bytecode state (so the first punt triggers a lazy bytecode compile), maximizing overlap between the mutator’s and the compiler thread’s calls to byteCodeCompileIfNecessary.
- Corruption — Win the race so the interpreter reads a torn m_regExpBytecode or a partially constructed BytecodePattern. The resulting memory-safety violation happens deep inside Yarr::interpret over attacker-influenced pattern data.
- Escalation — Any controllable corruption of the bytecode pattern executed by the interpreter could be steered toward OOB access during matching; however this is speculative — no primitive is demonstrated.
- Reality check — Crash-only and race-dependent. The maintainers themselves could only manifest it with injected sleeps, so real-world exploitation would require substantial timing control over both threads.
Detection & hunting
For defenders and SOC / detection engineers:
- TSAN / race reports —
- Crash locus —
- Version gating —
Audit directions
- Compiler-thread reachable lazy compiles —
- MatchFrom coverage —
- cellLock discipline —
- Concurrent constant-folding of regexps —