← All reports

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

MediumJSC YARROOB

e536815

Medium으로 평가한 이유는, wrapper가 byte-code interpreter에게 넘긴 buffer가 interpreter 자신이 기록한 요구 크기보다 짧아서, pattern이 선택한 index에 대한 write가 buffer 끝을 넘어가기 때문입니다. High band에 들지 않는 이유는, attacker가 영향을 미칠 수 있는 pattern string이 이 façade로 들어가는 in-tree caller가 확인되지 않고, container의 over-allocation이 작은 규모의 overrun을 흡수하기 때문입니다.

JSC의 정규식 매칭은 결과를 "offsets vector"라는 형태로 만들어냅니다. 이는 unsigned 값들의 flat array로, 각 capture group이 두 개의 연속된 slot(start offset, end offset)을 차지하며 slot 0과 1은 전체 match를 위해 예약되어 있습니다. 이 엔진을 사용하는 독립적인 소비자는 두 곳입니다. JavaScript에 노출되는 RegExp 객체를 지원하는 JSC::RegExp와, JavaScriptCore 외부 caller를 위해 export된 더 단순한 wrapper인 JSC::Yarr::RegularExpression입니다. 두 소비자 모두 byte-code의 자체 기록된 요구 크기 이상 길이의 vector를 byte-code interpreter에게 넘겨야 합니다. Interpreter가 compile 시점에 박혀 있는 id로 이 vector를 직접 indexing하기 때문입니다.

관전 포인트: duplicate named capture group을 가진 pattern이 byte-code interpreter로 하여금 wrapper가 할당한 buffer 끝을 넘어선 index에 write하도록 유도합니다. match()의 stack frame에, 또는 pattern이 클 경우 인접한 heap memory에 write가 발생합니다.

RegularExpression의 offsets vector 할당 크기가 잘못되어 있습니다. named captures가 추가될 때 해당 공식이 갱신되었지만, RegularExpression의 계산은 올바르게 갱신되지 않았습니다. 이 patch는 이를 수정합니다.

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 });
+ ...

프로덕션 코드 변경은 한 줄입니다. JSC::Yarr::RegularExpression::match()에서, Yarr::interpret()에 output vector로 넘기는 Vector<unsigned, 32> nonReturnedOvector의 크기를 정하는 local 변수 offsetVectorSize는 이전에는 wrapper 자체의 공식인 (d->m_numSubpatterns + 1) * 2로 계산되었습니다. 이 patch는 이를 byte-code compiler 자신이 기록한 크기, 즉 d->m_regExpByteCode->m_offsetsSize로 교체합니다.

match()의 나머지 부분은 변경되지 않습니다. nonReturnedOvector.grow(offsetVectorSize) 호출, offsetNoMatch 초기화 loop(여전히 m_numSubpatterns + 1로 범위가 제한됨), 그리고 interpret() 호출은 그대로입니다. 나머지 hunk들은 부수적인 변경입니다. Flags::UnicodeSets 아래에서 duplicate named capture group을 가진 pattern에 대해 RegularExpression 객체를 생성하는 네 개의 TEST(JavaScriptCore_RegularExpression, DuplicateNamedCaptureGroup*) 케이스를 담은 새로운 API test 파일과, Tools/TestWebKitAPI/CMakeLists.txt의 한 줄짜리 등록이 그것입니다. 이 테스트들이 trigger의 근거입니다. 모두 (?<a>x)|(?<a>y) 형태의 pattern, 즉 같은 group 이름이 여러 alternative에서 재사용되는 형태를 사용합니다.

producer가 기록해 둔 크기를 읽지 않고, consumer가 자신만의 공식으로 buffer 요구 크기를 다시 계산하는 패턴.

Offsets vector. YARR의 match 출력은 unsigned 값들의 flat array입니다. 각 capture group마다 interpreter가 start offset과 end offset을 연속된 두 slot에 저장하며, slot 0/1은 전체 match를 위해 예약되어 있습니다. offsetNoMatch는 참여하지 않은 group의 slot에 기록되는 sentinel 값입니다. YarrPattern::m_numSubpatterns가 capturing group의 개수를 세므로, 전형적인 ovector 길이는 (numSubpatterns + 1) * 2입니다.

Duplicate named capture groups. 같은 group 이름이 pattern 안에서 서로 배타적인 alternative에 속하는 한 여러 번 등장할 수 있도록 허용하는 언어 기능입니다. 예를 들면 (?<a>x)|(?<a>y) 같은 형태입니다. YARR은 이를 이름별 id로 추적하고, BytecodePattern::offsetForDuplicateNamedGroupId(id)를 통해 해당 slot에 접근합니다. 이 slot들은 기존의 start/end 쌍 뒤에 위치합니다. BytecodePattern::m_offsetsSize는 interpreter가 필요로 하는 unsigned slot의 개수를 byte-code 객체가 기록해 둔 값입니다.

하나의 엔진, 두 개의 소비자. JSC::RegExp(runtime/RegExp.cpp)는 offsetVectorBaseForNamedCaptures()m_rareData->m_numDuplicateNamedCaptureGroups를 더해 m_ovector의 크기를 정합니다. JSC::Yarr::RegularExpression(yarr/RegularExpression.cpp)은 독립적이고 더 단순한 wrapper입니다. YarrPatternbyteCompile()interpret()로 이어지는 얇은 façade이며, JS_EXPORT_PRIVATE로 선언되어 JavaScriptCore 외부에서도 호출 가능합니다. 이 wrapper는 match() 호출마다 자체적인 scratch vector를 할당합니다.

Vector<T, N>의 inline capacity와 growth 정책. WTF의 Vector 템플릿에서 두 번째 파라미터는 inline capacity입니다. N개까지의 element는 Vector 객체 자체에 내장된 buffer에 저장되며, N을 넘어서는 요청만이 heap 할당을 유발합니다. grow()expandCapacity()를 거치는데, 이 함수는 max(requested, max(16, capacity() + capacity() / 4 + 1))만큼 예약하므로, heap 성장 이후의 예약 capacity는 요청한 크기보다 일반적으로 더 큽니다. WTF의 VectorTraits는 simple/POD element type을 needsInitialization = false로 표시하므로, Vector<unsigned>를 growth시키면 내용이 특정되지 않은(zero가 아닌) element가 노출됩니다. nonReturnedOvectormatch()의 stack local로서 Vector<unsigned, 32>로 선언되어 있습니다.

interpret(). YARR byte-code interpreter의 진입점입니다. BytecodePattern, subject string, start offset, 그리고 raw unsigned* output vector를 받아, byte code에 박혀 있는 id로 이 vector를 직접 indexing합니다.

Root cause는 중복된 크기 공식 중 한쪽만 갱신된 것입니다. Duplicate named capture group 지원이 추가되었을 때, offsets vector는 offsetForDuplicateNamedGroupId(id)로 접근하는 추가 trailing 영역만큼 늘어났습니다. Producer는 이 실제 총 크기를 m_offsetsSize에 기록하고, RegExp::finishCreation()은 이 값을 올바르게 사용합니다. 반면 wrapper가 손으로 작성한 (m_numSubpatterns + 1) * 2 공식은 손대지 않은 채 남아 있었습니다.

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

Interpreter는 이 slot들을 index로 직접 건드립니다. ParenthesesDisjunctionContext의 constructor는 subpatternAndGroupIdBackup[...] = output[m_pattern->offsetForDuplicateNamedGroupId(duplicateNamedGroupId)](read)를 수행한 뒤 output[pattern->offsetForDuplicateNamedGroupId(duplicateNamedGroupId)] = 0(write)을 수행하고, restoreOutput()이 저장된 값을 다시 write합니다. 낡은 공식 하에서는 이런 index 하나하나가 (m_numSubpatterns + 1) * 2 지점 또는 그 이후에 위치하게 됩니다.

실제로 어떤 메모리가 손상되는지는 grow()에 넘긴 크기가 아니라 vector의 예약된 capacity가 결정하며, 이 둘은 서로 다른 방향으로 어긋납니다. 낡은 크기 값이 32 이하로 유지되는 동안에는 storage가 embedded inline buffer이고 그 capacity는 정확히 32입니다. 이때 touched index가 32 이상이면 — 대략 열다섯 개의 subpattern에 몇 개의 duplicate named group이 더해진 정도면 — embedded buffer를 넘어 match()를 감싸는 stack frame에 write가 발생합니다. 반면 낡은 크기 값이 32를 넘어서면 grow()expandCapacity()를 거치게 되고, 32 element짜리 inline buffer에서 벗어나는 성장은 최소 41개의 element를 예약합니다. 32를 살짝 넘는 낡은 크기 값에서 몇 slot 정도 overrun이 발생하는 정도라면, 여전히 vector 자신의 heap 할당 안, 즉 초기화되지 않은 여유 공간 안에 머무르게 됩니다. 인접한 heap memory에 도달하려면 touched index의 최댓값이 실제 예약된 capacity를 넘어서야 하는데, 큰 pattern의 경우 이 capacity는 대략 낡은 크기 값의 1.25배 수준을 따라갑니다. 결과적으로 조용히 넘어가는 구간은 heap path에서는 단순한 추정보다 넓고, inline path에서는 보이는 것보다 좁습니다.

Fix 이후에도 눈여겨볼 만한 잔여 문제가 하나 있습니다. Buffer는 이제 m_offsetsSize 길이가 되었지만, 명시적인 offsetNoMatch seeding loop는 여전히 m_numSubpatterns + 1에서 멈추기 때문에, trailing duplicate-group slot들은 wrapper에 의해 seeding되지 않은 채 남습니다. WTF의 VectorTraits는 simple/POD type을 초기화가 필요 없다고 표시하므로 grow()가 이 slot들을 zero로 채우지 않습니다. 이 부분의 정확성은 결국 interpreter가 read 이전에 해당 slot을 초기화한다는 전제에 의존하는데, 적어도 ParenthesesDisjunctionContext 경로에서는 그렇게 동작하는 것으로 보이지만, interpret()의 모든 경로에서 이 조건이 성립하는지는 확인되지 않습니다.

발견 경로는 fuzzing보다는 pattern auditing이나 variant analysis에 가까운 것으로 보입니다. Fix 자체가 낡은 공식 한 줄이고, 추가된 테스트들은 모두 Flags::UnicodeSets 아래 duplicate named capture group을 사용하는 손으로 작성한 API test라는 점이, 기능이 merge된 이후 누군가가 offsets-vector layout의 소비자들을 의도적으로 하나씩 점검한 흔적에 해당합니다. Fuzzing이 이 버그를 찾기 어려운 이유는, 새로 추가된 테스트를 포함한 가장 작은 pattern들조차 touched index를 inline buffer 안쪽에 머물게 하고, heap growth 정책이 요청 크기보다 넉넉히 over-allocation하기 때문입니다. 결국 pattern이 touched index를 예약 capacity 너머로 밀어내기 전까지는 sanitizer 리포트가 나타나지 않습니다.

이 vulnerability는 RegularExpression façade를 통해 정규식을 compile하는 프로세스 내부의 memory safety를 약화시킵니다. Fix 이전에 깨져 있던 invariant는 byteCompile()interpret() 사이의 계약입니다. Output vector는 최소한 m_offsetsSize개의 unsigned를 담아야 하는데, wrapper는 pattern에 duplicate named capture group이 포함될 때마다 이보다 더 적은 크기를 조용히 넘겼습니다. Pattern string을 이 API에 흘려넣을 수 있는 attacker라면, touched index가 vector의 예약 capacity를 넘어설 때마다 pattern이 선택한 index에 대한 out-of-bounds write를 얻을 수 있습니다. Storage가 여전히 embedded 32-element inline buffer인 경우라면 match()의 stack frame에, touched index가 over-allocated된 예약 heap capacity를 넘어서는 경우라면 인접한 heap memory에 write가 발생하는 형태입니다. 두 경우 모두 직접 사용되기보다는 grooming이나 stack-layout 지식과 결합되는, 제한적인 memory corruption 발판에 해당할 것으로 보입니다. Attacker가 영향을 미치는 pattern string을 이 façade에 넘기는 in-tree caller는 여기서 확인되지 않으며, 이 점이 JS-visible RegExp 경로에 있는 동등한 버그보다 실질적인 severity를 낮게 유지시키는 요인입니다.

Takeaway: Vector<T, N>의 "작은 overflow"를 triage할 때는 요청한 길이가 아니라 container의 예약 capacity를 계산해야 합니다. Inline capacity 이하에서는 경계가 정확히 N이며 overrun이 감싸는 stack frame으로 그대로 새어 나가지만, 그 이상에서는 expandCapacity()의 over-allocation이 소규모 overrun을 조용히 흡수해 ASan으로부터 숨겨버립니다.

이 코드가 있는 위치가 아니라 audit direction 번역이므로, 별도 스킬 없이 바로 번역 규칙에 따라 처리하겠습니다.