116394de19 [JSC] Yarr JIT \B+unicode+surrogate produces corrupted JSString, crashes WebContent
Triage note: Word-boundary reads under the non-BMP optimization corrupt the match index, yielding a corrupted JSString and WebContent crash - JIT memory-safety bug.
Contents
The bug at a glance
The flaw is in the JSC Yarr regex JIT, invoked whenever a page runs a RegExp, so it is reachable from any script with no permission. A word-boundary assertion combined with the unicode flag and the non-BMP fast path corrupts the match index register, producing a JSString whose backing indices are wrong – a memory-safety defect the commit says crashes WebContent. It is scoped as index/string corruption leading to a crash rather than a demonstrated controllable write, so high rather than critical.
A page evaluates a unicode-flag RegExp that contains a word-boundary assertion (\b or \B) against a string containing a non-BMP (surrogate-pair) code point. The JIT’s optimization that reads the first non-BMP character adds an extra amount to the match index; the word-boundary code path failed to account for it, leaving the index/firstCharacterAdditionalReadSize corrupted so the resulting match string is built from bad offsets.
Root cause
YarrJIT compiles regular expressions to machine code. Under ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) with ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP), the generator uses an optimization tracked by m_useFirstNonBMPCharacterOptimization together with a register m_regs.firstCharacterAdditionalReadSize: when a term reads a non-BMP code point (encoded as a surrogate pair in a 16-bit string), the index advances by an extra unit, and that extra amount is carried in firstCharacterAdditionalReadSize so later index adjustments stay consistent.
The word-boundary assertion generator reads the character before and at the current position to decide word-ness. This code was emitted while m_useFirstNonBMPCharacterOptimization was active, so its character reads participated in the non-BMP read accounting and corrupted firstCharacterAdditionalReadSize – the register no longer reflected the real extra read size for the subsequent matching. In the back-tracking/re-entry path for alternatives (the second hunk), when the optimization is on the JIT does m_jit.add32(m_regs.firstCharacterAdditionalReadSize, m_regs.index) before adjusting index by (delta - 1) and re-checking input; with the value corrupted by the boundary assertion, m_regs.index is advanced by a bogus amount.
A corrupted m_regs.index means the match’s start/end offsets into the subject string are wrong. When JSC materializes the match result, it constructs a JSString/substring from those offsets, yielding a string whose length or backing pointer range is inconsistent with the real subject buffer – the “corrupted JSString” the bug title describes – which crashes WebContent when the string is used. The regression test /\B|c./u.exec(“a\ud800\udc00”)[0][0] combines an alternation whose first branch is the \B assertion with a unicode subject containing U+10000 (surrogate pair \ud800\udc00); the expected result is undefined, and any other value indicates the index corruption.
The fix has two parts. In the word-boundary assertion generator it wraps the emission in SetForScope useOptimizationScope(m_useFirstNonBMPCharacterOptimization, false) (guarded by the two ENABLE macros), disabling the non-BMP optimization for the duration of the boundary-assertion code so its reads cannot touch firstCharacterAdditionalReadSize. In the re-entry hunk it splits behavior on m_useFirstNonBMPCharacterOptimization: only when the optimization is genuinely active does it add firstCharacterAdditionalReadSize to index and linkTo via checkInput(); otherwise it keeps the original sub32(delta-1)+jump path. Together these keep the index register consistent when boundary assertions and non-BMP input mix.
Key code
Fix: disable the non-BMP first-character optimization inside the word-boundary assertion generator
const MacroAssembler::RegisterID character = m_regs.regT0;
const MacroAssembler::RegisterID scratch = m_regs.regT1;
#if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP)
// Prevent word boundary assertion reads from corrupting firstCharacterAdditionalReadSize.
SetForScope useOptimizationScope(m_useFirstNonBMPCharacterOptimization, false);
#endif
MacroAssembler::Jump atBegin;
MacroAssembler::JumpList matchDest;
if (!term->inputPosition)
Patch walkthrough
Source/JavaScriptCore/yarr/YarrJIT.cpp (word-boundary generator)— Adds, guarded by ENABLE(YARR_JIT_UNICODE_EXPRESSIONS) && ENABLE(YARR_JIT_UNICODE_CAN_INCREMENT_INDEX_FOR_NON_BMP),SetForScope useOptimizationScope(m_useFirstNonBMPCharacterOptimization, false);at the start of the boundary-assertion code so its character reads do not corrupt firstCharacterAdditionalReadSize. Includes <wtf/SetForScope.h>.Source/JavaScriptCore/yarr/YarrJIT.cpp (alternative re-entry)— Rewrites the index-adjustment on back-track re-entry: when m_useFirstNonBMPCharacterOptimization is set it does add32(firstCharacterAdditionalReadSize, index), optional sub32(delta-1), then checkInput().linkTo(beginOp->m_reentry); otherwise it retains the prior sub32(delta-1)+jump(beginOp->m_reentry) behavior.JSTests/stress/regexp-boundary-assertion-with-surrogate.js— New regression test:/\B|c./u.exec("a\ud800\udc00")[0][0]must be undefined; throws otherwise. Exercises the \B assertion under the unicode flag against a surrogate-pair subject.
Background
Yarr JIT — Yet Another Regex Runtime’s JIT compiler in JavaScriptCore, which compiles JS regular expressions into native code for speed. Because it emits raw machine code that indexes into string buffers, register-tracking bugs there are memory-safety issues.
Word-boundary assertion (\b / \B) — A zero-width regex assertion that matches (\b) or does not match (\B) at a transition between a word and non-word character. The JIT must read the characters on both sides of the current index to evaluate it.
Non-BMP / surrogate pairs — Code points above U+FFFF (e.g. U+10000, written \ud800\udc00) are stored as two UTF-16 code units. With the /u flag, the engine treats such a pair as one code point, so advancing past it moves the index by two units.
firstCharacterAdditionalReadSize / m_useFirstNonBMPCharacterOptimization — A JIT optimization that, when the subject may contain non-BMP characters, tracks the extra code unit consumed by a surrogate pair in a dedicated register so index adjustments remain correct. The bug let word-boundary reads clobber this register.
Corrupted JSString — A JSString whose stored length/offset does not match its backing buffer. Constructing one from a bad match index leads to reads outside the real string data when the string is later accessed, crashing WebContent.
Vulnerability window
- Introduction — The word-boundary assertion generator emitted its character reads while the non-BMP first-character optimization was active, entangling them with firstCharacterAdditionalReadSize.
- Trigger — A page runs a /u RegExp with a boundary assertion against a subject containing a surrogate pair, e.g. /\B|c./u.exec(“a\ud800\udc00”).
- Register corruption — The boundary-assertion reads perturb firstCharacterAdditionalReadSize; on the alternative re-entry path this bad value is added to m_regs.index.
- Index corruption — The match’s start/end offsets into the subject become wrong.
- String corruption / crash — JSC builds the result JSString from the corrupted offsets, producing a string with inconsistent length/backing range; using it crashes WebContent.
- Fix — 315250@main scopes the optimization off inside the boundary generator and gates the re-entry index arithmetic on the optimization actually being active.
Proof of concept
The added stress test evaluates a unicode-flag RegExp whose first alternative is the \B word-boundary assertion against the subject “a\ud800\udc00” (an ASCII ‘a’ followed by the surrogate pair for U+10000). On a correct engine the zero-width \B match at position 0 makes [0][0] undefined. On the vulnerable JIT the match index is corrupted, so the produced JSString is malformed and indexing it returns a wrong value or crashes WebContent. Simply running the file in JSC (or evaluating the regex in a page) reproduces the condition.
const result = /\B|c./u.exec("a\ud800\udc00")[0][0];
if (result !== undefined)
throw "Expected undefined, got " + result;
Exploitation
- Trigger — Attacker script evaluates a crafted /u RegExp with a word-boundary assertion on a surrogate-containing subject – trivially reachable from any web page.
- Primitive — Produces a corrupted match index and hence a malformed JSString. The commit frames the observable outcome as a WebContent crash (memory-safety violation).
- Potential escalation — A JSString with attacker-influenced length/offset could in principle be a stepping stone to OOB string reads; the patch and test only demonstrate corruption/crash, so treat controllable exploitation as unproven from this artifact.
- Honest note — This is best characterized as a JIT memory-safety bug with a reliable crash; any read/leak primitive would need further analysis of how the corrupted index bounds the resulting string.
Detection & hunting
For defenders and SOC / detection engineers:
- WebContent crashes in Yarr JIT / string construction —
- RegExp patterns mixing boundary assertions, /u, and surrogates —
- Fuzzer-like RegExp bursts —
Audit directions
- Other assertion generators under the optimization —
- Index arithmetic on re-entry paths —
- BMP vs non-BMP code paths —
- Result string construction —