[YARR] Fix incorrect offset when reading pattern character for Unicode backreference in JIT
CVE: CVE-2026-43740 · Safari 26.5.2 · Released June 29, 2026 Impact: 악의적으로 조작된 웹 콘텐츠를 처리하는 과정에서 process memory가 노출될 수 있습니다 Apple's description: The issue was addressed with improved memory handling. Credit: Nathaniel Oh (@calysteon), Arni Hardarson
Medium — 상수가 들어가야 할 자리에 한 토큰짜리 offset 표현식이 들어가 있고, 그 잘못된 토큰은 script가 제어할 수 있는 값입니다. commit message는 이를 단순한 매칭 실패로 설명하지만, 실제 주소 연산을 보면 capture가 position 0 근처에 있을 때마다 같은 displacement가 string buffer 앞쪽을 벗어나 읽습니다. 반대편에서 얻을 수 있는 것은 match/no-match oracle뿐입니다.
JavaScriptCore에서 정규표현식 매칭은 기계어 코드로 컴파일되며, 기계어 코드에는 컴파일러가 직접 넣어주지 않는 한 bounds check가 존재하지 않습니다. YARR의 JIT는 개별 term마다 있던 input-length check를 여러 term의 run 단위로 끌어올려 속도를 확보하는데, 이 때문에 컴파일러가 내보내는 모든 load는 컴파일 시점의 bias를 갖게 되고, 이 bias는 사용 지점에서 다시 빼줘야 합니다. 이 bias는 정확히 하나의 커서, 즉 subject string을 따라가는 커서에만 속하는 값입니다. 그런데 backreference 매칭은 두 개의 커서를 동시에 운용합니다.
관전 포인트: 웹페이지는 매칭 결과가 문자열 시작 이전 메모리에서 읽은 문자에 좌우되는 정규표현식을 만들어낼 수 있습니다. 인접한 heap 바이트에 대한 script-visible oracle이 성립하는 셈입니다.
Source/JavaScriptCore/yarr/YarrJIT.cpp
JSTests/stress/regexp-backreference-unicode-offset.js
Patch Details
기능적으로 바뀐 부분은 YarrGenerator::matchBackreference() 안의 딱 한 줄입니다. m_decodeSurrogatePairs 분기, 즉 패턴이 16비트 텍스트에 대해 u 또는 v 플래그를 갖는 경로에서, 생성된 코드는 이전에 캡처된 문자를 로드하여 현재 subject position의 문자와 비교합니다. 이 load는 원래 readCharacter(op.m_checkedOffset - term->inputPosition, character, patternIndex)로 작성되어 있었습니다. 패치는 첫 번째 인자를 상수 0으로 교체합니다.
readCharacter()의 첫 번째 인자는 load 주소를 형성할 때 index register에서 빼는 negative displacement입니다. 이 값은 YARR가 subject cursor에 걸어둔 speculative bias를 되돌리기 위해 존재합니다. 그런데 여기서 넘겨지는 index register는 subject cursor가 아니라 patternIndex, 즉 capture 안의 absolute position입니다. 같은 if/else 안에서 세 줄 위에 있는 non-Unicode 분기는 이미 0을 넘기고 있었습니다. 패치는 이 두 분기를 일치시킵니다.
이번 commit에는 JSTests/stress/regexp-backreference-unicode-offset.js도 추가되었습니다. 이 테스트는 /(.)\1c/u, /(.)\1cd/u와 대소문자 무시 변형들을 non-BMP subject에 대해 500회 실행합니다. YARR JIT 컴파일을 강제로 유발할 만큼 충분한 반복 횟수로, interpreter에 머물지 않도록 설계되었습니다. 파일 구조 자체가 trigger 조건을 그대로 담고 있습니다. 두 번째 case에 달린 코멘트는 backreference 뒤에 아무것도 없는 /(.)\1/u가 "이미 정상 동작했다"고 명시합니다.
Background
YARR. YARR는 JavaScriptCore의 정규표현식 엔진입니다. 두 개의 실행 tier로 구성되는데, bytecode interpreter인 YarrInterpreter와, 패턴이 컴파일할 만큼 충분히 자주 실행된 뒤 기계어 코드로 컴파일하는 YarrJIT입니다. 아래 내용은 모두 JIT tier에만 해당합니다.
Backreference와 두 개의 커서. \1 같은 backreference term은 같은 match 안에서 group 1이 이전에 캡처한 텍스트와 정확히 일치하는 부분을 찾습니다. 이를 매칭하려면 캡처된 substring을 따라가면서 현재 position의 subject와 code unit 단위로 비교해야 합니다. capture가 같은 subject buffer 안의 range이기 때문에, JIT는 backreference를 매칭하는 동안 같은 buffer를 가리키는 두 개의 커서를 유지합니다. 하나는 현재 match position인 index이고, 다른 하나는 output array의 capture-start slot으로부터 초기화되는, 이전 capture 내부의 absolute position인 patternIndex입니다.
Speculative input check와 m_checkedOffset. YARR는 term마다 length check를 내보내지 않습니다. 대신 여러 term의 run 전체를 커버하는 check 하나를 내보내고, 각 term이 이미 검증된 span 안에서 앞으로 인덱싱하도록 합니다. 그 결과 subject cursor는 어떤 term에서든 그 term 고유의 논리적 position보다 앞서 있는 상태가 됩니다. m_checkedOffset은 얼마나 앞서 있는지를 기록하고, term->inputPosition은 해당 term이 위치한 지점을 기록합니다. 따라서 subject cursor를 키로 삼는 load는 이를 보정하기 위해 negative offset으로 m_checkedOffset - inputPosition을 넘깁니다.
readCharacter(). readCharacter(negativeOffset, resultReg, indexReg)는 characters + (indexReg - negativeOffset) * charSize 위치의 문자 하나를 로드하는 코드를 생성합니다. 이 함수는 주소를 형성하는 primitive일 뿐이며, 그 이상의 검증은 수행하지 않습니다. 특정 load가 갖는 bounds 보장은 readCharacter() 자체가 아니라 주변의 checkInput 메커니즘에서 나옵니다.
m_decodeSurrogatePairs. 이 플래그는 패턴이 16비트 텍스트에 대해 u/v 플래그를 사용할 때 설정되며, lead+trail surrogate pair를 두 개의 독립된 unit이 아니라 하나의 code point로 처리하기 위한 것입니다. 이 경로는 문자 load를 공용 헬퍼인 tryReadUnicodeChar()를 통해 처리합니다. non-Unicode 경로처럼 목적지에 곧바로 로드하는 대신 표준 resultReg를 사용한 뒤 patternCharacter로 옮기는 이유가 여기에 있습니다.
Non-BMP escape. \u{10000}은 두 개의 UTF-16 code unit으로 인코딩되는 code point를 나타냅니다. 따라서 u 플래그가 붙은 패턴에서 . 하나는 subject의 두 unit을 소모하게 되며, 이것이 regression test의 subject가 "문자" 하나당 두 unit으로 구성된 이유입니다.
Analysis
근본 원인은 좌표 공간의 불일치입니다. subject cursor에 속하는 bias가 이미 absolute한 index에 적용된 것입니다.
Before: After:
patternIndex (absolute capture pos) patternIndex (absolute capture pos)
│ │
├─ delta = checkedOffset ├─ offset = 0
│ - inputPosition │
▼ ▼
load @ chars + (patternIndex-delta) load @ chars + patternIndex
│ │
├─ delta ≤ patternIndex → wrong char └─► correct captured char
└─ delta > patternIndex → OOB read
(before buffer start)
다이어그램에서 delta는 op.m_checkedOffset - term->inputPosition입니다. 이 값이 0이 되는 경우는 패턴에서 backreference 뒤에 아무것도 오지 않을 때뿐입니다. 그 경우에만 speculative check span이 term이 끝나는 지점에서 끝나고, m_checkedOffset이 inputPosition과 같아지기 때문입니다. /(.)\1/u는 정상적으로 매칭되었지만 /(.)\1c/u는 그렇지 않았던 이유가 바로 여기에 있고, regression test가 이 두 패턴을 대조 케이스로 짝지은 이유이기도 합니다. backreference 뒤에 term을 추가하면 delta는 그 term들이 소모하는 code unit 수만큼 커집니다. ASCII 리터럴 문자 하나당 1, non-BMP escape 하나당 2씩입니다. 즉 delta는 패턴 텍스트의 함수이며, 다시 말해 정규표현식을 작성하는 쪽이 직접 선택할 수 있는 값입니다.
다이어그램 왼쪽 열의 두 결과는 단 하나의 비교로 갈립니다. delta <= patternIndex인 경우, displace된 주소는 여전히 subject string 내부에 있으므로, 생성된 코드는 현재 input 문자를 잘못된 캡처 문자와 비교하는 데 그칩니다. commit message가 설명하는 매칭 실패 증상입니다. 반면 delta > patternIndex인 경우 index가 0을 지나 underflow되며, load는 문자열의 character buffer 앞쪽 메모리를 읽습니다. 이를 막는 장치는 없습니다. YARR가 내보내는 checkInput guard들은 subject cursor의 전방 이동을 제약할 뿐, patternIndex에 대해서는 아무것도 보장하지 않습니다. 그리고 readCharacter()는 자신의 인자로부터 bound를 다시 유도하지 않습니다.
// readCharacter(negativeCharacterOffset, resultReg, indexReg)
// → load @ characters + (indexReg - negativeCharacterOffset) * charSize
// No comparison, no branch. The address is formed and the load is issued.
underflow에 도달하려면 패턴 작성자가 함께 제어할 수 있는 두 가지 조건이 필요합니다. backreference 뒤에 delta를 충분히 끌어올릴 만큼의 term이 있어야 하고, capture는 patternIndex가 그보다 낮게 유지될 만큼 subject 시작 지점 가까이에서 시작해야 합니다. 둘 다 script에서 흔히 다루는 패턴 및 input 구성일 뿐, 특별한 heap 상태가 필요하지 않습니다. 되돌아오는 것은 script가 직접 읽을 수 있는 값이 아닙니다. 로드된 code unit은 backreference 매칭 여부를 결정하는 equality 비교에만 사용됩니다. 따라서 관찰 가능한 결과는 string buffer 앞쪽 바이트에 대한 match/no-match 응답, 즉 oracle입니다. delta를 바꿔가며 반복하면 이 oracle은 StringImpl header word와 그 인접 heap 내용을 읽어낼 수 있으며, 이는 ASLR 우회나 이후 corruption chain의 정찰 단계에 해당하는 형태입니다. 이는 WebContent process 내부의 memory-safety confinement를 약화시키는 결과이며, 그 자체로 메모리를 손상시키지는 않습니다. disclosure 범위도 renderer의 address space 안에 머무르므로, cross-process 영향으로 이어지려면 별도의 memory-corruption 버그와 별도의 sandbox escape가 추가로 필요합니다. 이번 패치는 pattern character가 정확히 patternIndex에서, 즉 patternIndex가 실제로 속한 좌표 공간에서 로드되도록 하는 invariant를 복원합니다.
subject cursor를 위한 pattern-controlled bias가 absolute capture cursor에 잘못 적용되어, regexp 매칭 결과가 string buffer 이전 메모리에 좌우되는 상황이 만들어졌습니다.
Insight
올바른 코드는 이미 세 줄 위에 있었습니다. non-Unicode 분기는 0을 넘기는데 Unicode 분기만 bias 표현식을 넘기고 있었습니다. 하나의 논리적 연산에 대한 짝을 이루는 특화 구현들, 즉 Unicode 대 non-Unicode, 8비트 대 16비트, inline 대 helper-call 같은 구조는 YARR 버그의 반복적인 원인입니다. 드물게 실행되는 분기가 공통 경로에서 서서히 어긋나고, 훨씬 적은 테스트로만 검증되기 때문입니다. 또한 주목할 점은, security 관점의 결과가 commit message에는 전혀 드러나지 않는다는 것입니다. 작성자는 이를 단순히 "매칭에 부정확하게 실패하는" 문제로만 설명하고 있으며, capture가 position 0 근처에 있을 때 같은 잘못된 offset이 buffer 앞쪽을 벗어난다는 사실은 오직 주소 연산을 통해서만 드러납니다. correctness로 프레이밍된 YARR JIT offset 수정은 바로 이런 이유로 다시 한번 들여다볼 가치가 있습니다.
Audit directions
-
One offset, two coordinate spaces. Invariant는 모든 bias 인자가 그것이 유도된 커서와 짝을 이뤄야 한다는 것입니다. Narrow:
Source/JavaScriptCore/yarr/YarrJIT.cpp에서readCharacter(,readCharacterDontDecode(,negativeOffsetIndexedAddress(호출 지점을 검색하여, index register 인자가 기본값인index(patternIndex,endIndex,matchPos)가 아닌 경우를 찾고, 이때 offset 인자가 상수0인지 확인하십시오. 기본이 아닌 index register와m_checkedOffset기반의 non-constant 표현식이 함께 쓰인 경우가 이번 버그와 동일한 패턴입니다. Wider: 이 class는 speculative-bias 변수와 absolute cursor가 함께 존재하는 코드베이스 어디에서든 나타날 수 있습니다.YarrInterpreter.cpp의 backreference 및 Boyer-Moore search index 연산, 그리고 JSC의 string-search fast path에서checkedOffset/lookaheadbias와 output 또는 capture array에서 로드된 offset이 섞여 있는 표현식을 확인하십시오. 검색 결과에서는 compile-time bias와 runtime에 로드된 position이 결합된 연산을 눈여겨봐야 합니다. Widest: 이는 lookahead cursor와 backreference cursor를 함께 갖는 모든 matcher나 codec에 존재하는 "두 개의 좌표 공간, 하나의 offset" class에 해당합니다. V8의 irregexp backreference 코드, PCRE2의 JIT, read pointer와 dictionary pointer를 동시에 추적하는 decoder가 대표적입니다. 이어받아야 할 invariant는, 변수 이름이 어느 좌표 공간에 속하는지를 나타낸다면 그런 두 변수를 섞는 연산은 모두 버그 후보라는 점입니다. -
Loads that inherit their bounds guarantee from a different register. Invariant는 load 주소를 형성하는 register가 곧 bounds-check된 register여야 한다는 것입니다. Narrow:
YarrJIT.cpp의 각checkInput/checkNotEnoughInput지점을 점검하여 그 guard가 어떤 register를 제약하는지 확인하고, 해당 guard와 다음 guard 사이에서 base index가 다른 register인 load를 모두 나열하십시오.matchBackreference()와backtrackBackReference()내부의patternIndex기반 load들이 그 시작점입니다. Wider: 같은 class는 bounds check를 영역 단위로 hoisting하거나 공유하는 JIT 어디에서든 나타납니다. 한 index에 대한CheckInBounds뒤에 파생되거나 alias된 index를 쓰는 load가 이어지는 DFG/FTL array-access lowering, 그리고 재사용된 index temporary에 대한 Wasm bounds-check elision이 그 예입니다. guard와 load가 서로 다른 index 변수를 지칭하는 것이 신호입니다. Widest: 재사용 가능한 원칙은, bounds check가 증명하는 것은 메모리 영역이 아니라 값이라는 점이며, 이는 check elision이나 hoisting을 수행하는 모든 컴파일러(V8 Turbofan, SpiderMonkey Ion, LLVM의 elision assumption)에 적용됩니다. 어떤 SSA 값이 range 안에 있음을 증명받았는지, 그리고 그 값이 실제로 주소를 형성하는 데 쓰인 값과 같은지를 항상 물어야 합니다. -
paired specialised implementation 사이의 divergence. Invariant는 paired fast/slow 또는 wide/narrow implementation이 공통 입력에 대한 결과뿐 아니라 argument semantics 자체에서 일치해야 한다는 점입니다. Narrow:
YarrJIT.cpp에서#if ENABLE(YARR_JIT_UNICODE_EXPRESSIONS)/m_decodeSurrogatePairs분기를 non-Unicode 대응 분기와 나란히 놓고 비교해야 합니다. 두 분기가 동일한 helper에 구조적으로 다른 argument를 전달하는 지점을 모두 표시하십시오. 한쪽은 constant, 다른 쪽은 computed expression인 경우가 바로 매치 신호이며, 이번 버그의 형태와 정확히 일치합니다. Wider: 동일한 side-by-side diffing을 WebKit의 다른 duplicated-specialisation 표면에도 적용해야 합니다. 8-bit vs 16-bitStringImplcode path,WTFtext search의 SIMD vs scalar fallback, 동일 JIT helper의 inlined vs thunk-call variant 등이 해당됩니다. Sibling 간 argument list의 형태가 다른 helper 호출을 찾아야 합니다. Widest: 하나의 알고리즘을 N개의 specialised copy로 유지하는 모든 코드베이스, 예를 들어 encoding-specialised parser, vectorised/scalar kernel pair, per-architecture backend 등은 동일한 위험군에 속합니다. 이식 가능한 점검 방법은 하나의 공유 입력 corpus로 모든 specialisation을 구동하고 출력을 비교하는 differential testing입니다. -
out-of-string data에 의존하는 match 결과. 이런 경로는 소리 없이 disclosure oracle로 전환되며, 바로 이 지점이 correctness bug를 CVE로 만든 요인입니다. Narrow:
matchBackreference()와 character-class matcher 안의 각 생성된 comparison에 대해 두 비교 대상 operand의 provenance를 추적해야 합니다. 그리고 중간에 guard 없이 runtime-loaded position으로부터 만들어질 수 있는 address가 있는지 확인해야 합니다. 신호는 성공 또는 실패 여부가 match 결과, capture 내용, 또는lastIndex를 통해 script에서 관찰 가능하면서, 그 입력 중 하나가 guard 없는 load에서 온 comparison입니다. Wider and beyond WebKit: 동일한 질문이 untrusted input에 대해 match 성공 여부를 노출하면서 backreference나 dictionary cursor에 unchecked load를 수행하는 모든 pattern engine에 적용됩니다.