← All reports

[3] GetByStatus walked the prototype chain for direct property access

HighJSC bytecode profiling and DFG JITTypeConfusion

A private field read that answers with the prototype's value

b07dca9

High. 하나의 분석 루틴이 두 가지 연산을 동시에 처리하고 있었는데, 이 둘의 실질적인 차이는 lookup이 어디까지 탐색을 허용하는지뿐이었습니다. 이후 변경이 이 루틴에 더 넓은 범위를 탐색하도록 가르치면서 문제가 생겼습니다. 결과적으로 컴파일된 코드가, 언어 스펙상 miss가 보장되는 상황에서도 own-property hit으로 보고할 수 있게 됩니다 — brand-check bypass에 해당합니다. Memory-safety 확장 여부는 이 check을 layout의 증거로 신뢰하는 consumer가 있는지에 달려 있습니다.

JavaScript의 property load에는 두 가지 방식이 있습니다. 하나는 object를 조회한 뒤 prototype chain을 따라가는 일반 lookup이고, 다른 하나는 object 자신에서 멈춰야 하는 own-property-only lookup입니다. GetByStatus는 property-get bytecode가 수행하는 동작을 컴파일러가 요약한 것으로, State와 어떤 base shape에 적용되는지·값이 어디에 있는지를 설명하는 GetByVariant 목록으로 구성됩니다. DFG의 abstract interpreter와 constant-folding phase는 모두 이 정보를 참조해 access를 inline하거나 고정 offset으로 fold할 수 있는지 판단합니다. 이 phase들이 지켜야 하는 soundness 규칙은, optimizing tier가 어떤 연산을 재작성할 때 명세된 동작과 관찰상 동등한 결과로만 바꿀 수 있다는 것입니다.

관전 포인트: class private-field read와 self-hosted builtin의 own-property probe가, 언어 스펙상 undefined나 TypeError가 보장되는 상황에서도 DFG-compiled code에서 prototype의 값을 반환하도록 만들 수 있습니다. 일반 script에서 도달 가능한 brand-check bypass입니다.

When computing the GetByStatus, we should check if the property lookup is a direct property access before doing a prototype walk since direct accesses are not supposed to consult the prototype. Originally landed as 305413.572@rapid/safari-7624.2.5.110-branch.

Source/JavaScriptCore/bytecode/GetByStatus.cpp

-GetByStatus GetByStatus::computeFor(JSGlobalObject* globalObject, const StructureSet& set, CacheableIdentifier identifier)
+GetByStatus GetByStatus::computeFor(JSGlobalObject* globalObject, const StructureSet& set, CacheableIdentifier identifier, GetByStatus::LookupMode mode)
{
// For now we only handle the super simple self access case. We could handle the
// prototype case in the future.
 
- //
 
- // Note that this code is also used for GetByIdDirect since this function only looks
 
- // into direct properties. When supporting prototype chains, we should split this for
 
- // GetById and GetByIdDirect.
...
 
- if (auto result = attempToFold())
 
- return result.value();
+ // We should do a prototype walk searching for the property only if this
+ // is a normal access. Don't consult proto for for direct accesses.
+ if (mode == GetByStatus::LookupMode::Normal) {
+ if (auto result = attempToFold())
+ return result.value();
+ }

Source/JavaScriptCore/dfg/DFGNode.h

+ GetByStatus::LookupMode propertyLookupMode()
+ {
+ switch (op()) {
+ case GetByIdDirect:
+ case GetByIdDirectFlush:
+ case GetPrivateNameById:
+ return GetByStatus::LookupMode::Direct;
+ case GetById:
+ case GetByIdFlush:
+ case GetByIdMegamorphic:
+ return GetByStatus::LookupMode::Normal;
+ default:
+ RELEASE_ASSERT_NOT_REACHED();
+ }
+ }

Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp

 
- GetByStatus status = GetByStatus::computeFor(m_graph.globalObjectFor(node->origin.semantic), baseValue.m_structure.toStructureSet(), identifier);
+ GetByStatus::LookupMode lookupMode = node->propertyLookupMode();
+ GetByStatus status = GetByStatus::computeFor(m_graph.globalObjectFor(node->origin.semantic), baseValue.m_structure.toStructureSet(), identifier, lookupMode);

이번 변경은 GetByStatus::computeFor의 structure-set overload에 새로운 lookup-scope discriminator를 추가로 넘겨주고, DFG node 스스로가 자신의 opcode가 어떤 scope를 의미하는지 보고하도록 만들었습니다. 그리고 세 곳의 call site가 이 값을 전달하도록 수정되었습니다.

GetByStatus.h에는 enum class LookupMode : bool { Normal, Direct }가 추가되었고, overload는 computeFor(JSGlobalObject*, const StructureSet&, CacheableIdentifier, LookupMode)로 바뀌었습니다. GetByStatus.cpp에서는 기존에 무조건 실행되던 if (auto result = attempToFold()) return result.value(); 부분이 mode == LookupMode::Normal 조건 아래로 들어갔습니다. 또한 "이 루틴은 direct property만 조회한다"는 잘못된 내용을 담고 있던 기존 주석과, prototype chain을 지원하게 되면 GetByIdGetByIdDirect를 분리해야 한다는 TODO도 함께 삭제되었습니다.

DFGNode.h에는 Node::propertyLookupMode()가 추가되어, GetByIdDirect·GetByIdDirectFlush·GetPrivateNameByIdLookupMode::Direct로, GetById·GetByIdFlush·GetByIdMegamorphicLookupMode::Normal로 매핑됩니다. 그 외 케이스는 RELEASE_ASSERT_NOT_REACHED()로 처리됩니다. DFG의 두 consumer인 AbstractInterpreter<>::executeEffectsConstantFoldingPhase::foldConstants는 이제 node->propertyLookupMode()를 전달하며, ByteCodeParser::parseBlockop_get_from_scope global-property 경로는 LookupMode::Normal을 명시적으로 전달합니다. 이 경로에는 global property lookup이 원래 global object의 prototype chain을 조회하도록 되어 있다는 주석이 함께 달려 있습니다.

하나의 lookup 루틴을 서로 다른 lookup scope를 가진 두 연산이 공유하면서, prototype-chain 탐색이 own-property-only access에 조용히 새어 들어간 사례입니다.

이 코드가 있는 위치. GetByStatus는 JSC가 property get을 bytecode 수준에서 요약한 정보로, State(Simple, Megamorphic, LikelyTakesSlowPath 등)와 GetByVariant 목록으로 구성됩니다. 각 GetByVariant는 어떤 base shape에 적용되는지를 나타내는 StructureSet, object storage 내 값의 위치를 나타내는 PropertyOffset, 그리고 ObjectPropertyConditionSet을 갖습니다. GetByVariant.h에는 "A non-empty condition set means that this is a prototype load"라고 명시되어 있습니다.

Structure. Structure는 object의 shape를 기술하며 property name을 offset으로 매핑합니다. structure->getConcurrently(uid)는 compiler thread에서 structure의 own property offset을 조회하는 데 사용됩니다.

세 가지 get 연산. GetById는 object를 조회한 뒤 prototype chain을 따라가는 일반 JavaScript property lookup을 구현합니다. GetByIdDirect는 own-property-only load를 구현하며, JSC의 self-hosted builtin JavaScript가 prototype getter를 호출하지 않고 object 자신의 상태를 확인할 때 사용됩니다. GetPrivateNameById는 class private-field read(obj.#x)를 구현하는데, 언어 규칙상 해당 field가 바로 그 object에 설치되어 있을 때만 성공해야 합니다.

Abstract interpretation과 folding. DFG abstract interpreter(AbstractInterpreter<>::executeEffects)는 graph를 순회하며 각 node의 값에 대한 사실을 증명합니다. 어떤 사실이 증명되면 이후 phase가 runtime check를 생략할 수 있게 됩니다. ConstantFoldingPhase는 이 사실들을 소비해 node를 재작성하는데, Simple 상태에 variant가 하나뿐이라면 일반적인 get을 CheckStructure와 고정 offset GetByOffset의 조합으로, 또는 JSConstant로 대체할 수 있습니다.

op_get_from_scope. scope object에서 변수를 읽어오는 bytecode로, global-object property를 읽는 경우도 포함합니다.

이 버그의 본질은 semantic mismatch입니다. optimizer가 own-property-only 연산을 prototype-chain lookup 의미론으로 모델링하고 있었습니다.

  child = Object.create(p)                Lookup scope per opcode
  ┌──────────────┐                        GetById            : child -> p -> ...
  │ child        │  own: (none)           GetByIdDirect      : child only
  │  [[Proto]] ──┼──┐                     GetPrivateNameById : child only
  └──────────────┘  │
                    v
             ┌──────────────┐
             │ p            │  own: #x @ offset 0
             └──────────────┘

  Pre-fix fold of a Direct-mode node over child's structure set:
    attempToFold() walks to p, returns Simple variant with a
    non-empty conditionSet and p's offset -> compiled code reads p.#x

GetByIdDirect / GetPrivateNameById node의 base가 finite structure set을 가진다고 증명된 상태(executeEffectsvalue.m_structure.isFinite(), foldConstantsbaseValue.m_structure.toStructureSet())에서도, 그 property가 set 안의 모든 structure에서 부재할 수 있습니다. 이 상태에서 direct-access의 올바른 의미론은 GetByIdDirect라면 undefined, field가 없는 object에 대한 private-name load라면 TypeError여야 합니다. 하지만 패치 이전에는 attempToFold()가 그대로 실행되어 해당 structure들의 prototype chain을 탐색했습니다. prototype이 그 identifier를 갖고 있으면, computeFor는 prototype chain을 기술하는 variant와 prototype의 storage를 가리키는 offset을 담은 Simple status를 반환했습니다. 이후 ConstantFoldingPhase가 이 node를 structure check와 condition check로 보호된 prototype load로 재작성하고, abstract interpreter는 node의 결과를 prototype property가 증명하는 값으로 narrowing합니다.

하나의 root cause에서 두 가지 결과가 이어집니다. 첫째, 올바르게 guard된 input에 대해 컴파일된 코드가 잘못된 값을 계산합니다. 둘째, abstract interpreter가 연산의 실제 의미론으로는 나올 수 없는 result type을 단정하게 됩니다 — downstream type check를 생략시키는 전형적인 "AI lies" 전제 조건에 해당합니다.

attempToFold()가 반환하는 값의 정확한 형태는 직접 확인된 것이 아니라 정황을 통해 도출한 내용입니다. 추가된 주석은 이 함수가 prototype walk를 수행한다는 점을 확인해주고, GetByVariant.h는 non-empty condition set이 prototype load를 의미한다고 문서화하고 있습니다. 다만 attempToFold()의 본문 자체는 제공된 GetByStatus.cpp의 truncation 지점 너머에 있어 직접 확인되지는 않습니다.

두 node family 모두 web content에서 도달 가능합니다. GetPrivateNameById는 일반적인 class private-field read에서 emit되고, GetByIdDirect/GetByIdDirectFlush는 일반 JS가 호출하는 self-hosted builtin 내부에서 emit됩니다. 따라서 페이지가 hot loop를 통해 둘 중 어느 쪽이든 DFG tier까지 끌어올릴 수 있습니다. Trigger 형태는 다음과 같습니다.

  1. class C { #x = 1; static read(o) { return o.#x; } }.
  2. #x를 own property로 갖는 instance p를 만듭니다.
  3. #x를 own property로 갖지 않는 look-alike child = Object.create(p)를 만듭니다.
  4. C.readchild의 abstract structure set을 가진 base에 대해 hot하게 실행하되, throw되는 케이스를 try/catch로 감싸 함수가 계속 tier up되도록 합니다.

패치 이전에는 foldConstantsbaseValue.m_structure.toStructureSet()으로 computeFor를 호출하고, attempToFold()child의 prototype chain을 탐색해 #xp에서 찾은 뒤 p의 offset을 가진 Simple variant를 반환합니다. DFG는 guard된 prototype load를 emit하므로, 컴파일된 C.read(child)가 interpreter에서는 TypeError가 발생하는 상황에서 1을 반환할 수 있었습니다. builtin 내부의 own-property probe도 같은 형태의 문제를 겪습니다. Object.create(realInstance)로 만든 look-alike의 own-property probe는 원래 undefined를 읽어야 하지만, DFG 하에서는 prototype의 값을 읽어버려 instance brand check로서의 probe 용도가 무력화될 수 있습니다.

logic bypass를 넘어선 확장 여부는, 이 bypass된 check을 layout의 증거로 신뢰하는 consumer가 존재하는지에 달려 있습니다. builtin이나 downstream JIT-visible 경로가 이 bogus probe 성공 이후 look-alike의 고정된 internal-field slot을 계속 건드린다면, type-confused access로 이어질 가능성이 있습니다. builtin의 소스 코드는 제공된 context에 포함되어 있지 않으므로, 이 단계는 예상되는 방향으로만 제시합니다. 이와는 별개로, abstract-interpreter 호출 지점을 기준으로 삼는 두 번째 확장 시나리오도 있습니다. executeEffects는 fold된 prototype variant로부터 node의 AbstractValue를 narrowing하므로, direct-access의 실제 의미론으로는 나올 수 없는 type을 증명해버릴 수 있습니다. 이 증명을 근거로 check이 생략된 downstream edge가 있다면, 잘못된 type의 값을 다루게 될 가능성이 있습니다.

이 vulnerability는 interpreter의 언어 의미론과 DFG-compiled code 사이의 soundness 경계를 약화시킵니다. own-property-only load가 prototype-chain load로 재작성될 수 있었기 때문에, property를 own하지 않는 object가 — 컴파일된 코드에서만 — 그 property를 own한 것처럼 보일 수 있습니다. 이 상태에 web content에서 도달한 공격자는, JavaScript 코드가 (JSC 자체의 self-hosted builtin을 포함해) 진짜 instance와 조작된 look-alike를 구분하기 위해 의존하는 own-property 및 private-field brand check을 우회할 수 있고, 언어가 접근 불가를 보장하는 private 또는 internal 상태를 읽어낼 수 있습니다. 이렇게 우회된 check의 downstream consumer가 특정 object layout을 가정하고 동작한다면, logic bypass에서 memory-safety primitive로 확장될 가능성이 있습니다.

삭제된 주석 자체가 이 문제의 배경을 말해줍니다. 원래 이 공유 루틴은 자신의 correctness precondition("이 함수는 direct property만 조회한다")을 문서화하고, 그에 대한 해법("GetById와 GetByIdDirect를 분리해야 한다")까지 명시하고 있었습니다. 이후 변경이 prototype을 탐색하는 attempToFold()를 같은 함수에 추가하면서, 주석은 그대로 둔 채 이 precondition을 깨뜨렸습니다. 이번 fix는 바로 그 TODO가 요구했던 분리를, 두 함수 대신 하나의 parameter로 구현한 것입니다. 그리고 Node::propertyLookupMode()RELEASE_ASSERT_NOT_REACHED() default는, 앞으로 이 경로를 타는 어떤 새로운 node type이라도 자신의 lookup 의미론을 명시적으로 선언하도록 강제합니다.

Bug hunting 관점에서 보면, 특히 세 가지 지점이 눈에 띕니다.