[2] LiteralParser commits a cached transition invalidated by a `__proto__` setter
The literal parser knew __proto__ could run script, and guarded the wrong thing.
High. Nested parse가 실행되기 전에 캡처된 hidden-class snapshot이, attacker JavaScript를 실행한 이후에도 그대로 커밋됩니다. 그 결과 선언된 shape와 실제 backing store가 일치하지 않는, script에서 도달 가능한 객체가 만들어집니다. 이 상황이 info leak으로 확장되는지는 attacker가 어떤 indexing-type 조합을 구성할 수 있는지에 달려 있습니다.
Hidden-class 기반 엔진은 모든 객체의 layout을 공유된 shape descriptor로 표현합니다. Interpreter, inline cache, JIT speculation 등 엔진 내부의 모든 fast path는 이 descriptor가 객체의 실제 storage와 일치한다는 전제를 신뢰합니다. JSC의 LiteralParser는 JSON.parse와 순수 object literal의 평가를 처리하는데, 이때의 object fast path는 캐시된 shape transition을 따라가며 값을 resolved offset에 직접 기록하는 방식으로 동작합니다. 범용 property-store 로직을 거치지 않는 셈입니다. 문제는 이 fast path가 property의 값을 파싱하기 전에 해당 property의 transition을 먼저 resolve해둔다는 점입니다. Receiver의 shape가 write가 이루어지는 시점까지도 snapshot 당시와 동일할 것이라는 가정에 기대고 있습니다.
관전 포인트: 어떤 페이지든 __proto__ 멤버와 prototype에 설치된 setter를 포함한 object literal을 eval하기만 하면, Structure가 기술하는 layout과 실제 butterfly가 서로 어긋난 JS 객체를 얻을 수 있습니다. 순수 script만으로 도달 가능한 type confusion입니다.
LiteralParser에는 이미 존재하는 transition을 가진 literal을 파싱할 때 이를 캐시해 활용하는 fast path가 있었습니다. 이 캐싱은 실제로 object literal이 파싱되기 이전에 이루어졌습니다. 문제는 실제 파싱 과정에서 __proto__에 대한 setter로 인해 user code가 실행될 수 있다는 점입니다. 이 경우 원본 객체의 structure가 변경되면서, 앞서 캐시해둔 transition도 함께 무효화됩니다. 이번 PR은 파싱 도중 structure가 변경된 경우 slow path를 taking하도록 하여 이 문제를 수정합니다.
Source/JavaScriptCore/runtime/LiteralParser.cpp
Patch Details
LiteralParser<CharType, reviverMode>::parseRecursively에서 object-literal fast path는 property value를 파싱하기 전에 receiver의 shape를 지역 변수에 스냅샷으로 저장합니다. 이번 패치는 해당 지역 변수 이름을 structure에서 originalStructure로 바꾸어 네 곳의 사용 지점 모두에서 이 값이 오래된(stale) 정보일 수 있음을 명시적으로 드러냅니다. 또한 property value를 만들어내는 재귀 parseRecursively 호출 직후에 재검증 단계를 추가합니다. object->structure() != originalStructure이고 이전에 계산된 property variant가 여전히 ExistingProperty { structure, offset }를 갖고 있는 경우, 이 variant는 Identifier::fromUid(vm, std::get<ExistingProperty>(property).structure->transitionPropertyName())로 덮어써지며, 그 결과 cached-transition fast path 대신 일반적인 property-store path를 강제로 타게 됩니다.
Cached-transition 자체의 계산 로직은 변경되지 않았습니다. 여전히 transition이 attribute 없는 단순 TransitionKind::PropertyAddition인 경우에는 originalStructure->trySingleTransition()을 따라가고, 그렇지 않으면 Structure::addPropertyTransitionToExistingStructure(originalStructure, ident, 0, offset)을 호출합니다. 다운스트림에서 originalStructure->outOfLineCapacity()와 newStructure->outOfLineCapacity()를 비교하고 object->allocateMoreOutOfLineStorage(...)를 호출한 뒤 object->nukeStructureAndSetButterfly(vm, originalStructure->id(), newButterfly)를 호출하는 fast-path 블록은, 이제 스냅샷이 여전히 최신 상태일 때만 도달 가능합니다. 부수적으로 Identifier::fromUid(VM&, UniquedStringImpl*)에는 Identifier.h에 NODELETE 선언이, IdentifierInlines.h의 inline 정의에는 SUPPRESS_NODELETE가 추가되었습니다.
callback 경계 이전에 캡처한 object-layout descriptor를, 이미 callback에 의해 layout이 바뀐 object에 그대로 적용하는 패턴입니다.
Background
Structure.
JSC의 hidden-class object입니다. JSObject의 property 이름, 각 property의 offset, object의 indexing type, out-of-line(butterfly) capacity를 기록합니다. 같은 shape를 공유하는 object들은 동일한 Structure를 공유합니다.
Property-addition transition.
Property를 추가하면 기존 Structure로부터 파생된 새 Structure가 생성됩니다. Transition은 캐시되므로, 같은 object shape를 반복해서 만들 때는 동일한 chain이 재사용됩니다. Structure::trySingleTransition()은 structure에 outgoing transition이 정확히 하나만 있을 때 그 캐시된 transition을 반환합니다. transitionPropertyName()과 transitionOffset()은 해당 transition이 어떤 property를 추가하고 어디에 배치하는지를 설명합니다. Structure::addPropertyTransitionToExistingStructure()는 주어진 이름에 대해 이미 존재하는 transition을 새로 만들지 않고 조회합니다.
Butterfly.
JSObject에 매달린 out-of-line storage 블록입니다. Inline slot을 초과하는 named property는 왼쪽으로, indexed element는 오른쪽으로 확장됩니다. outOfLineCapacity()는 현재 butterfly가 보유한 named-property slot의 개수이며, allocateMoreOutOfLineStorage(vm, oldCapacity, newCapacity)는 재할당과 복사를 수행합니다. nukeStructureAndSetButterfly는 structure ID를 일시적으로 "nuked" 상태로 표시하여, 동시에 실행 중인 다른 thread가 butterfly와 structure의 쌍을 교체 중간 상태로 관찰하지 않도록 합니다.
Indexing type.
Structure의 일부로, engine에게 butterfly의 indexed 영역을 어떻게 해석할지 알려줍니다. Contiguous와 ArrayStorage는 서로 다른 butterfly layout을 가지며, ArrayStorage는 vector length, public length, index bias, sparse-map pointer를 담는 header를 앞에 붙입니다.
"Having a bad time".
Object.prototype 같은 prototype에 indexed accessor가 설치될 때 트리거되는 JSC의 전역 de-optimization 이벤트입니다. Fast indexing type을 가진 object들을 slow indexed storage로 전환시켜, indexed store가 prototype chain을 확인하도록 만들며, 그 과정에서 기존 object들의 indexing type과 layout이 다른 경우에는 butterfly까지 다시 씁니다.
LiteralParser fast path.
괄호로 감싼 object/array literal에 대한 eval과 JSON.parse는 모두 LiteralParser를 거칩니다. Object fast path는 receiver가 JSFinalObject임을 이미 알고 있으며, 캐시된 transition을 따라가며 resolve된 offset에 값을 직접 기록하는 방식으로 object를 구성합니다.
Object literal 안의 __proto__.
LiteralParser는 m_visitedUnderscoreProto를 추적하며 __proto__ 멤버를 특별하게 취급합니다. 이 key를 저장하는 동작이 일반 data property를 정의하는 대신 accessor로 dispatch될 수 있기 때문입니다. Object.prototype.__defineSetter__("__proto__", fn)은 내장 accessor를 사용자 함수로 교체합니다.
Re-entrancy. Native engine code가 JavaScript를 호출하는 지점으로, 이 JavaScript가 동기적으로 실행되면서 반환 전에 engine 상태를 변경할 수 있는 위치를 의미합니다.
Analysis
이 버그는 JS re-entrancy 지점을 사이에 둔 time-of-check-to-time-of-use 간극입니다. 그 결과로 Structure와 butterfly 사이의 desynchronization이 발생하는데, 이는 단순한 잘못된 값 버그가 아니라 type confusion에 해당합니다.
Before the fix:
originalStructure = object->structure() shape S0, Contiguous
│
├─► resolve ExistingProperty{ S0->S1, offset } (cached transition)
│
└─► parseRecursively(value) ── {__proto__: 0} ──► user setter runs
│
Object.prototype.__defineSetter__(0, fn)
│
global "having a bad time" invalidation
rewrites object: S0 -> S0', butterfly relaid
│
┌─────────────────────────────────────────────────────┘
▼
commit ExistingProperty{ S1, offset } ← S1 derived from S0, not S0'
allocateMoreOutOfLineStorage(vm, S0->outOfLineCapacity(), ...)
nukeStructureAndSetButterfly(vm, S0->id(), ...) ← stale StructureID
result: object carries S1 (old indexing type) over S0' butterfly layout
Nested parse 안에서 실행되는 user code는 아직 만들어지는 중인 object를 이름으로 참조할 수 없지만, 전역적으로 이 object를 변형시킬 수는 있습니다. Object.prototype에 indexed accessor를 설치하면 JSC의 전역 "having a bad time" invalidation이 트리거되며, 이 과정에서 heap 전체를 순회하면서 fast indexed storage를 가진 object들의 indexing type을 다시 씁니다. Contiguous에서 ArrayStorage로 전환되는 경우에는 butterfly layout까지 다시 씁니다. 지금 구성 중인 literal은 "0", "1", "5"라는 index 형태의 key를 가지고 있으므로, 정확히 이런 조건에 해당하는 object입니다. Control이 복귀했을 때, 패치 이전 코드는 stale한 ExistingProperty를 그대로 적용했습니다. Capacity 비교는 invalidation 이전 structure를 기준으로 이루어지고, allocateMoreOutOfLineStorage는 stale한 old capacity를 현재 크기인 것처럼 사용해 호출될 수 있으며, nukeStructureAndSetButterfly는 object가 더 이상 가지고 있지 않은 ID로 호출되고, stale offset에 값을 저장하며, invalidation 이전 계보로부터 파생된 transition target을 설치하게 됩니다.
패치는 재귀 호출 이후 object->structure()를 다시 읽고, 불일치가 있으면 Identifier slow path로 내려갑니다. 이 slow path는 receiver의 현재 structure로부터 transition을 다시 도출합니다.
회귀 테스트는 fast path가 반드시 실행되도록 정교하게 구성되어 있습니다. 단계별로 살펴보겠습니다.
Object.prototype.__defineSetter__("__proto__", fn)은 상속된__proto__accessor를 attacker JS로 교체합니다.ks = '"0":null,"1":2,"5":3'은 literal에 hole이 있는 세 개의 index 형태 key를 부여하므로, 생성되는JSFinalObject는 indexed storage를 갖게 됩니다.- 두 차례의 warm-up
eval("({" + ks + ",a:1})")호출은 transition chain을 예열시킵니다. 그 결과 세 번째 실행에서는 keya에 대한 transition 조회가 성공하여Identifierpath로 떨어지지 않고ExistingProperty { newStructure, offset }를 반환하게 됩니다. - 세 번째 eval에서 parser는 key
a에 도달해 이ExistingProperty를 resolve하고 캐시한 뒤, value인{__proto__:0}에 대해parseRecursively로 재귀합니다. - Nested object에
__proto__를 저장하면 user setter가 dispatch되고, 이 setter는Object.prototype.__defineSetter__(0, function(){})를 실행합니다. 이로써 prototype chain에 indexed accessor가 설치되고 engine 전역의 indexed-storage de-optimization이 트리거됩니다. - Control이 복귀할 때
object->structure() != originalStructure상태가 되는데, 패치 이전에는 stale한ExistingProperty가 그대로 커밋되었습니다.
테스트는 이 desync를 language level에서 관찰합니다. o[1]을 읽었을 때 더 이상 2가 반환되지 않습니다. 다만 구체적인 원인이 잘못된 indexing type으로 indexed slot을 읽는 것인지, stale offset에 대한 store인지, 아니면 잘못된 크기로 이루어진 copy인지는 테스트만으로 구분되지 않습니다. 테스트가 확인하는 것은 값이 잘못 읽힌다는 사실뿐입니다. 마찬가지로 "having a bad time"이라는 구체적인 원인 지목은 도메인 지식에 기반한 추론이며, diff의 주석이 확인해주는 사실은 user code가 실행되어 object의 structure를 바꿨을 수 있다는 정도입니다.
확장 시나리오를 살펴보면, invalidation 이전과 이후의 indexing type이 서로 다른 butterfly layout을 갖는 경우 — 즉 Contiguous와 ArrayStorage 사이의 전환 — 라면 문제가 더 커집니다. 이 경우 결과 object에 대한 indexed read는 이전 layout 기준으로 resolve되는 반면 실제 storage는 새 layout을 담고 있게 됩니다. ArrayStorage header와 겹치는 slot을 읽으면 length field나 sparse-map pointer가 JSValue로 노출될 수 있으며, 이는 heap-address disclosure primitive로 이어질 가능성이 있습니다. 대칭적으로, 이렇게 confusion이 발생한 fast path를 통한 indexed store는 해당 header word를 덮어쓸 수 있고, vector length를 부풀림으로써 out-of-bounds indexed access를 가능하게 만들 여지도 있습니다. 다만 둘 중 어느 경우든 실현되려면 몇 가지 조건이 충족되어야 합니다. (a) attacker가 invalidation 이전 object를, invalidation 이후와 layout이 다른 indexing type으로 유도해야 하고, (b) stale한 allocateMoreOutOfLineStorage / nukeStructureAndSetButterfly 시퀀스를 즉각적인 crash 없이 통과해야 하며, (c) confusion이 발생한 slot이 읽거나 손상시킬 가치가 있는 대상과 맞아떨어지도록 heap을 grooming해야 합니다. 별개로, source size로 사용되는 stale한 outOfLineCapacity()는 invalidation이 실제 out-of-line capacity를 바꾼 경우 named-property copy의 크기를 잘못 계산하게 만들 가능성도 있습니다.
이 vulnerability는 JSC 핵심 object-model의 type safety를 약화시킵니다. JSObject의 Structure가 실제 butterfly layout과 indexing type을 정확히 기술한다는 invariant가 깨지기 때문입니다. 패치 이전에는, literal parser가 user JavaScript에 의해 object가 다시 쓰이기 이전에 내려진 shape 결정을 그대로 커밋했기 때문에, web content가 선언된 shape와 실제 backing store가 불일치하는 JS object를 완전히 접근 가능한(reachable) 상태로 얻을 수 있었습니다. 이후의 모든 engine 코드 — interpreter의 indexed fast path, inline cache, DFG/FTL speculation — 는 Structure를 신뢰하므로, 유용한 shape 조합을 만들어낸 attacker는 선언된 shape상으로는 object에 속한 것처럼 보이지만 실제 butterfly layout으로는 다른 곳을 가리키는 메모리를 읽거나 쓸 수 있게 될 가능성이 있습니다. 이는 단순한 correctness 버그가 아니라, WebContent process 내에서 information disclosure와 out-of-bounds indexed access로 이어지는 발판에 해당합니다.
Fast path는 이미 __proto__가 re-entrancy hazard라는 사실을 알고 있었습니다. Transition lambda는 transitionPropertyName()이 underscoreProto인 transition을 명시적으로 따라가지 않도록 거부하며, m_visitedUnderscoreProto를 확인합니다. 다만 이 guard는 transition의 선택을 보호했을 뿐, 그 transition이 유래한 스냅샷의 신선도는 보호하지 못했습니다. 이는 반복적으로 나타나는 패턴입니다. 어떤 component가 하나의 invariant를 위해 명시적인 re-entrancy guard를 갖추게 되면, 같은 window에 의존하는 이웃 invariant들은 오히려 감사 대상에서 누락되기 쉽습니다. Guard가 존재한다는 사실 자체가 "이 경로는 이미 검토가 끝났다"는 인상을 주기 때문입니다. 아울러 이번 mutation은 object를 reference를 통해 접근한 적이 전혀 없다는 점도 주목할 만합니다. 전역 de-optimization 이벤트는 user code가 handle을 가지고 있지 않은 상태에서도 진행 중인 object를 다시 씁니다. 따라서 "이 object는 아직 script에 노출되지 않았다"는 사실이 재검증을 생략할 근거가 되지는 않습니다.
Audit directions
-
User code를 실행할 수 있는 호출 이전에 캡처된 shape descriptor. 여기서 지켜야 할 invariant는 캐시된 hidden-class, offset, capacity는 re-entrancy 경계를 지나면 무효화되며, 재사용이 아니라 다시 읽어야 한다는 것입니다. 좁게는,
JavaScriptCore/runtime에서object->structure()로 할당된Structure*타입 지역 변수(그리고 짝을 이루는PropertyOffset지역 변수)를 검색하되,parseRecursively,put,putDirect,defineOwnProperty,callGetter,call,toPrimitive호출을 넘어서까지 살아있는 경우를 찾아보십시오. 우선LiteralParser.cpp의 나머지 부분,ObjectConstructor.cpp(defineProperties,Object.assign/spread fast path),JSONObject.cpp의 reviver walk부터 점검할 필요가 있습니다. 넓게 보면, object에 대한 파생된 정보 — inline capacity, indexing type, butterfly pointer,PropertyTableslot,Watchpoint상태 — 가 한 번 계산된 뒤 engine control이 벗어난 이후에 사용되는 모든 지점에서 같은 클래스의 문제가 나타날 수 있으므로, array bulk 연산이나 shape 결정이 user-visible callback보다 앞서 이루어지는 IC 구성 코드도 함께 살펴볼 필요가 있습니다. 가장 넓게 보면, 이는 hidden-class 기반 engine 전반에서 나타나는 "shape 스냅샷이 mutation window보다 오래 살아남는" 일반적인 클래스의 버그입니다. V8의Object.definePropertyinterceptor 주변Map/TransitionArray캐싱, SpiderMonkey의 proxy trap 주변Shape조회, 그리고 user hook을 거치는 동안 schema descriptor를 캐싱하는 ORM이나 serializer 등에서도 동일한 패턴을 확인할 수 있습니다. 코드 리뷰에서 이를 알아채는 단서는, 이런 호출 위에서 선언된Structure*지역 변수가 그 아래에서 중간에object->structure()재조회 없이 그대로 역참조되는 형태입니다. -
현재 C++ 프레임이 참조 중인 객체를 재작성하는 전역 de-optimization 이벤트. 여기서 지켜져야 할 invariant는 네이티브 코드에서 구성 중인 객체는 JS 참조가 아직 없다는 이유만으로 안전하지 않다는 점입니다. 좁게 보면,
haveABadTime/havingABadTime과ObjectsWithBrokenIndexingFinder를 검색하여 스크립트가 이를 유발하는 경로를 모두 나열해야 합니다. prototype에 대한 indexed accessor, prototype-chain 변형,Object.freeze/seal,delete를 통한 dictionary transition 등이 이에 해당합니다. 이후 각 네이티브 object-construction 지점에서 nested store 도중 indexing-type이 안정적으로 유지된다고 가정하는 부분이 있는지 확인해야 합니다. 조금 더 넓게 보면, 같은 부류의 문제는 heap을 순회하며 무효화를 일으키는 모든 경우에 적용됩니다. structure의 dictionary화, inline cache를 교체하는 watchpoint 발동, GC로 인한 reshaping 등이 그 예입니다. 따라서 어떤 fast path가 allocate하거나 call-out이 가능한 operation 이전에indexingType()이나butterfly()를 캐싱해두는 지점이 있다면 점검 대상입니다. 가장 넓게 보면, 전역 무효화 이벤트가 진행 중인 객체를 소급적으로 재작성한다는 원리는 V8의 protector cell과 elements-kind normalization, 그리고 SpiderMonkey의 object-flag propagation에도 동일하게 적용됩니다. 각 단계에서 공통적으로 나타나는 tell은, 스크립트에서 도달 가능한 트리거를 가진 heap 전역 변형 pass와, call 도중 객체별 layout이 안정적이라고 가정하는 네이티브 코드가 함께 존재한다는 점입니다. -
하나의 invariant는 보호하지만 인접한 invariant는 놓치는 re-entrancy guard. 여기서
m_visitedUnderscoreProto와transitionPropertyName() != vm.propertyNames->underscoreProto라는 명시적 검사는__proto__store가 스크립트를 실행할 수 있다는 사실을 이미 인지하고 있었습니다. 그럼에도 같은 구간에서 structure-freshness invariant는 검증되지 않은 채로 남아 있었습니다. 좁게 보면, JavaScriptCore 전반에서visited*,*Reentrant*,inCallback,m_isParsing과 같은 이름의 멤버와 지역 변수를 검색하고, 각 플래그가 보호하는 구간 동안 유지되어야 하는 invariant를 전부 나열해야 합니다. 이때 플래그가 도입된 계기가 된 하나의 invariant뿐 아니라 모든 invariant가 실제로 재확립되는지 확인해야 합니다. 조금 더 넓게 보면, 같은 형태의 패턴은 부분적인 mitigation이 존재하는 모든 곳에서 나타납니다. allocation은 커버하지만 structure mutation은 커버하지 않는DeferGCscope, throw는 검사하지만 side effect는 검사하지 않는 exception check, 혹은 실제 위험 구간보다 좁게 설정된DisallowVMEntry/DisallowGCscope가 그 예입니다. 가장 넓게 보면, 하나의 hazard guard가 존재한다는 사실 자체가 그 hazard가 실재한다는 근거이지, 이미 처리되었다는 근거는 아닙니다. 코드 리뷰에서는, hazard class 전체가 일반적으로 존재하는 구간에 특정 property 이름이나 특정 callback 종류처럼 좁은 조건만을 명시한 방어 코드가 있다면 면밀히 살펴볼 필요가 있습니다. -
파생된 적 없는 structure를 대상으로 커밋되는 offset/capacity 쌍. 여기서 지켜져야 할 invariant는 offset은 그것이 파생된 structure를 기준으로 할 때만 의미를 가진다는 점입니다. 좁게 보면,
JavaScriptCore/runtime에서allocateMoreOutOfLineStorage와nukeStructureAndSetButterfly의 모든 호출 지점을 조사하여,oldCapacity인자와StructureID인자가 호출 시점에 객체의 현재 structure로부터 읽히는지, 이전 시점의 스냅샷으로부터 읽히는 것은 아닌지 확인해야 합니다. 여기서 tell은 두 인자 중 하나가 새로운object->structure()가 아니라 로컬 변수에서 가져와진다는 점입니다. 조금 더 넓게 보면, 같은 부류의 문제는 두 값이 서로 다른 시점에 샘플링되는 (container, index) 또는 (allocation, size) 쌍에 모두 적용됩니다.Structure의 flattening이나 dictionary transition 과정에서 이어지는PropertyOffset값도 여기에 포함됩니다. 가장 넓게 보면, 이는 핸들과 크기를 짝지어 다루는 어떤 코드베이스에도 적용되는, 서로 다른 시점에 샘플링된 index와 container 쌍이라는 일반적인 문제 클래스에 해당합니다. Rust에서&mutreborrow 주변의 slice index 캐싱이나, 스키마 재로드 사이에 필드 index를 캐싱하는 직렬화 포맷도 같은 예입니다. 공통적으로 나타나는 tell은, 서로 일관되게 유지되어야 하는 두 값이 그 사이에 어느 한쪽을 변형시킬 수 있는 call로 인해 분리된 별도의 읽기 시점에서 생성된다는 점입니다.