← All issues

[12] RegExp::byteCodeCompileIfNecessary not thread-safe vs concurrent compiler thread

Severity: Medium | Component: JSC Yarr regex engine | dcf25ed

RegExpstd::unique_ptr<BytecodePattern> 필드를 둘러싸고 mutator thread와 JSC concurrent compiler thread 사이에서 발생하는 TOCTOU race를 수정한 diff이기 때문에 Medium으로 평가되었습니다. 두 thread가 interleave되면 undefined behavior가 발생합니다. 다만 이를 유발하려면 매우 좁은 timing window가 필요하며, commit message에서도 인위적인 sleep 없이는 재현할 수 없다고 확인하고 있습니다.

RegExp::matchConcurrently는 컴파일을 유발해서는 안 됩니다. regexp의 JIT code가 존재하지 않는다면, 컴파일하는 대신 bail out하는 것이 올바른 동작입니다. 그러나 RegExp에 JIT code는 있지만 bytecode가 없는 상태가 가능합니다. 이 경우 matchConcurrently가 race 조건 속에서 bytecode 컴파일을 부적절하게 시도하는 상황이 발생할 수 있습니다.

Source/JavaScriptCore/runtime/RegExp.cpp

void RegExp::byteCodeCompileIfNecessary(VM* vm)
{
+ Locker locker { cellLock() };
+
if (m_regExpBytecode)
return;

Source/JavaScriptCore/runtime/RegExpInlines.h

if (result == static_cast<int>(Yarr::JSRegExpResult::JITCodeFailure)) {
- // JIT code가 expression을 처리할 수 없어 interpreter로 전달합니다.
- byteCodeCompileIfNecessary(&vm);
- if (m_state == ParseError)
- return throwError();
+ // bytecode 컴파일은 mutator만 수행할 수 있습니다. compiler thread는 이미 존재하는
+ // bytecode를 사용해야 하며, bytecode가 없으면 bail out합니다.
+ if constexpr (matchFrom == Yarr::MatchFrom::VMThread) {
+ byteCodeCompileIfNecessary(&vm);
+ if (m_state == ParseError)
+ return throwError();
+ }
+ if (!m_regExpBytecode)
+ return -1;

변경 사항은 두 가지입니다. 먼저 byteCodeCompileIfNecessary에서는 m_regExpBytecode를 확인하거나 쓰기 전에 cell lock(Locker locker { cellLock() })을 취득하도록 수정되었습니다. 한편 matchInline의 두 overload에서는 JIT 실패 시 fallback 경로가 재구성되었는데, byteCodeCompileIfNecessary 호출은 if constexpr (matchFrom == Yarr::MatchFrom::VMThread) 조건으로 gating되었습니다. bytecode가 생성되지 않은 경우 compiler thread가 bail out하도록 if (!m_regExpBytecode) return -1;도 함께 추가되었습니다. commit message에 따르면 matchInline 내부의 check는 의도적으로 cell lock을 취득하지 않습니다. 호출자인 matchConcurrently가 이미 lock을 보유하고 있기 때문입니다.

읽기 전용으로 동작해야 하는 JSC concurrent compiler thread와 mutator thread 사이에서, lazily compile된 cell 멤버를 둘러싼 TOCTOU race.

JSC는 정규식을 두 가지 형태로 lazy하게 컴파일합니다. JIT가 처리할 수 있는 패턴은 Yarr JIT machine code(m_regExpJITCode)로 컴파일됩니다. 나머지 패턴에 대해서는 Yarr::interpret가 해석하는 Yarr bytecode pattern(m_regExpBytecode)이 생성됩니다. 런타임에서 JIT로 컴파일된 regex code는 처리할 수 없는 입력에 대해 Yarr::JSRegExpResult::JITCodeFailure를 반환할 수 있습니다. 이 경우 실행은 bytecode interpreter로 전달됩니다. RegExp::matchConcurrently는 JSC의 concurrent compiler thread(DFG/FTL)에서 사용됩니다. 최적화 컴파일러가 컴파일 중에 regex match를 constant-fold하거나 speculation을 시도할 때, main VM thread가 아닌 별도의 thread에서 matching이 호출될 수 있습니다. concurrent compiler thread는 heap 상태를 변경하지 않고 관찰만 해야 한다는 규약이 있습니다. cellLock()은 cell 단위의 lock으로, mutator 측의 변경이 compiler thread 읽기에 가시적이어야 하는 드문 상황을 동기화하기 위해 사용됩니다. MatchFrom은 호출 컨텍스트가 VMThread인지 CompilerThread인지를 구분하는 template parameter입니다.

RegExp::matchConcurrently는 JSC concurrent compiler thread에서 실행되며, 기존의 JIT code를 읽는 것만을 목적으로 합니다. RegExp cell을 변경하는 것은 의도된 동작이 아닙니다. 그러나 JIT code는 존재하지만 bytecode는 없는 상태의 RegExp가 존재할 수 있습니다. 이 상황에서 JIT가 JSRegExpResult::JITCodeFailure를 반환하면, 수정 전 코드는 실행 중인 thread에 관계없이 byteCodeCompileIfNecessary를 무조건 호출했습니다. compiler thread도 예외가 아니었습니다. 한편 동일한 RegExp에 대해 일반적인 regex match를 실행하는 mutator thread도 같은 경로에 진입할 수 있습니다. 결과적으로 두 thread가 동시에 if (m_regExpBytecode)를 확인하는 상황이 발생할 수 있습니다. 양쪽 모두 null을 관찰하면, 각자 새로운 BytecodePattern을 할당하여 std::unique_ptr 멤버에 대입하게 됩니다.

🔒

Detailed thread-interleaving analysis and an assessment of whether this race is reachable and corruptible from web content.

더 확인하려면 구독해 주세요

🔒

Multiple reusable audit patterns identified across JSC's lazy-compilation and compiler-thread surfaces, with concrete grep targets for variant discovery.

더 확인하려면 구독해 주세요