[6] TypedArray constructor length read keyed on opaque-taint bit instead of Proxy identity
Low. 이번 patch는 opaque-taint가 설정된 slot 중 관찰 가능한 [[Get]]을 수행하는 범위를 좁혔습니다. 제공된 context에서는 이 차이가 allocation과 fill 사이의 크기 불일치로 이어지는 경로가 확인되지 않습니다. memory-safety 문제로 이어지려면 createUninitialized에 전달되는 allocation length가 copy loop가 가정하는 값과 어긋나야 합니다.
객체의 length로 새 buffer 크기를 정하는 엔진 builtin은, side effect가 없는 저렴한 조회와 관찰 가능한 전체 property read 중 하나를 선택해야 합니다. new Float64Array(x)가 iterable이 아닌 객체를 받으면, JSC는 PropertySlot(property 조회 결과를 담는 스택 레코드)으로 length를 조회합니다. 이때 Proxy나 module namespace 같은 exotic 객체는 slot을 opaque-tainted로 표시할 수 있습니다. 조회 결과를 단순한 값으로 신뢰할 수 없다는 의미입니다. 따라서 tainted slot에 대해 선택한 복구 경로는, 해당 taint를 만들 수 있는 모든 객체 종류에서 유효해야 합니다.
관전 포인트: 어떤 script든 Proxy나 module namespace를 기반으로 array-like 객체를 만들 수 있습니다. 이를 통해 JSC가 typed array length를 계산하는 경로를 원하는 쪽으로 유도할 수 있습니다. 남은 질문은 이런 유도가 allocation과 fill 사이의 불일치로까지 이어질 수 있는지입니다.
module namespace의
lengthbinding을 TypedArray length로 사용하지 않도록 합니다.VMInquiry는 Proxy와 module namespace 양쪽에 taint를 설정합니다. 그런데 이전의 Proxy fix는 tainted 객체 전부에 대해length를 읽었습니다. 그 결과length라는 이름의 namespace export가 typed array의 length로 사용되었습니다. 이번 patch는 inquiry가 Proxy에 도달한 경우에만 전체[[Get]]으로length를 읽습니다. prototype chain 위의 Proxy도 여기에 포함됩니다. module namespace는 inquiry 결과를 그대로 유지하며, 이 결과는lengthbinding을 읽지 않습니다.
Source/JavaScriptCore/runtime/JSGenericTypedArrayViewConstructorInlines.h
JSTests/stress/typedarray-constructor-proxy-array-like.js
Patch Details
이번 patch는 constructGenericTypedArrayViewWithArguments에서 array-like length를 계산하는 부분을 다시 작성했습니다. 이 함수는 iterable이 아닌 객체로 TypedArray를 생성할 때 거치는 경로입니다. patch 이전에는 분기가 세 개 있었습니다. opaque-tainted slot은 object->get(globalObject, vm.propertyNames->length)로 전체 조회를 수행했고, unset slot은 length 0을 사용했습니다. 나머지 경우에는 lengthSlot.getValue(...)를 호출했습니다.
수정 후에는 먼저 lengthSlot.slotBase()를 읽습니다. 그리고 slot이 opaque-tainted이면서 base가 null이 아닌 ProxyObjectType일 때만 전체 [[Get]]을 수행합니다. 값이 채워진 나머지 slot은 모두 getValue(...)를 거칩니다. unset slot은 jsUndefined()를 유지하며, 이 값은 toLength에서 0으로 변환됩니다. 결과적으로 세 갈래였던 후처리는 하나의 toLength 호출과 한 번의 exception check로 통합되었습니다.
실제 동작이 달라지는 범위는 base가 ProxyObject가 아닌 opaque-tainted slot으로 한정됩니다. 값이 채워진 slot은 다시 읽는 대신 slot 자신의 값을 사용합니다. 반대로 unset slot은 전체 [[Get]] 대신 length 0을 내놓게 됩니다. 새로 추가된 두 stress case는 모두 prototype chain에 Proxy를 배치합니다. 첫 번째는 Object.create(protoProxy)에 대해 trap log가 Symbol(Symbol.iterator),length,0,1이어야 함을 확인합니다. 두 번째는 chain 위의 revoked Proxy에 대해 TypeError가 발생해야 함을 확인합니다. 둘 다 좁혀진 조건이 지켜야 하는 Proxy 경로를 검증할 뿐, Proxy가 아닌 tainted slot을 다루지는 않습니다.
서로 다른 객체 종류가 공유하는 opaque-result flag를, 마치 한 종류만 식별하는 것처럼 사용한 패턴.
Background
Array-like 생성.
new Float64Array(x)가 ArrayBuffer, typed array, iterable 중 어느 것도 아닌 객체를 받으면, spec은 먼저 LengthOfArrayLike(x)(ToLength(Get(x, "length")))를 계산합니다. 이어서 그 크기의 typed array를 allocate합니다. 그다음 x[0]부터 x[length-1]까지 읽어 각 원소를 변환해 저장합니다.
PropertySlot과 VMInquiry.
PropertySlot은 property 조회 결과를 담는 JSC의 스택 할당 레코드입니다. 여기에는 찾은 값 또는 accessor, 그리고 property가 발견된 객체인 slotBase()가 들어 있습니다. slotBase()는 prototype chain 위의 조상 객체일 수도 있습니다. 그 밖에 cacheability flag와 m_isTaintedByOpaqueObject bit도 포함됩니다. 한편 InternalMethodType::VMInquiry는 엔진이 내부적으로 확인만 하는 조회 모드입니다. 이 모드에서 slot은 DisallowVMEntry를 설치합니다. 따라서 getOwnPropertySlot 구현은 JS 호출처럼 사용자가 관찰할 수 있는 동작을 수행해서는 안 됩니다.
Opaque taint.
순수한 조회만으로 답할 수 없는 exotic 객체는 setIsTaintedByOpaqueObject()로 slot에 표시를 남깁니다. 이 경우 PropertySlot::getPureResult는 cache된 값 대신 jsNull()을 반환합니다.
Proxy와 module namespace.
ProxyObject(JSType ProxyObjectType)는 property 연산을 사용자가 제공한 handler trap으로 전달합니다. get trap은 사용자 JS이므로 실제 [[Get]]에서만 실행됩니다. revoked Proxy는 어떤 trap 연산에서든 TypeError를 발생시킵니다. 반면 JSModuleNamespaceObject는 module의 export를 non-extensible own property로 노출하며, 그 값은 module environment의 변수 slot에 저장됩니다. import defer로 생성된 deferred namespace는 inquiry가 아닌 첫 property 접근 시점에 하위 module을 synchronous하게 평가합니다. 이 평가는 ensureDeferredNamespaceEvaluation을 통해 수행됩니다.
Analysis
이번 버그는 logic error에 해당합니다. 여러 의미로 쓰이는 taint flag를 기준으로 length 복구 경로를 잘못 분기한 경우입니다. fix 이전 코드는 집계 bit인 isTaintedByOpaqueObject() 하나만 보고 복구 방식을 결정했습니다. 즉 tainted slot이면 내용을 버리고 전체 object->get(length)로 대체했습니다.
Proxy라면 이 복구가 맞습니다. get trap이 사용자 JS이므로, 관찰 가능한 [[Get]] 없이는 답을 알 수 없기 때문입니다. prototype chain test도 fix가 이 동작을 유지한다는 점을 확인합니다. 문제는 이 bit가 Proxy 전용이 아니라는 데 있습니다. JSModuleNamespaceObject::getOwnPropertySlotCommon도 slot.setIsTaintedByOpaqueObject()를 호출합니다. 그래서 기존 코드는 bit를 설정하는 모든 객체에 Proxy 기준의 복구를 적용했습니다. fix는 전체 [[Get]] 수행 여부를 slotBase()의 구체적인 객체 종류로 판단하도록 변경되었습니다.
tainted length slot, by producer
Before: tainted ──► full object->get(length) (Proxy and namespace alike)
unset ──► 0
else ──► slot.getValue(...)
After: tainted && slotBase is ProxyObject ──► full object->get(length)
populated (incl. tainted non-Proxy) ──► slot.getValue(...)
unset (incl. tainted non-Proxy) ──► undefined ──► toLength ──► 0
module namespace에 대해서는 commit message가 동기를 밝히고 있습니다. 기존 로직에서는 length라는 이름의 export가 typed array length로 읽혔습니다. 수정된 코드는 length binding을 읽지 않는 inquiry 결과를 유지한다는 설명입니다. 다만 이로 인해 관찰 가능한 동작이 어떻게 달라지는지는 제공된 context로 확정되지 않습니다.
getOwnPropertySlotCommon의 non-inquiry 분기는 setValueModuleNamespace(this, ..., value, ...)를 통해 export binding의 값으로 slot을 채웁니다. namespace에 대한 전체 [[Get]]도 같은 binding을 읽습니다. spec의 LengthOfArrayLike가 요구하는 동작이 바로 이것입니다. deferred namespace가 실제 [[Get]]에서 synchronous하게 평가되는 것도 예상된 동작에 속합니다.
또한 VMInquiry에서 lengthSlot이 채워지는지도 판단할 수 없습니다. lengthSlot의 생성 부분과 getOwnPropertySlotCommon의 VMInquiry 분기가 source의 잘린 부분에 있기 때문입니다. 결국 Proxy가 아닌 어떤 producer에서 동작이 관찰 가능하게 달라지는지는 열린 질문으로 남습니다. fix 이전의 전체 [[Get]]이 그 producer에 어떤 구체적인 피해를 주었는지도 마찬가지입니다. Proxy가 아닌 경우를 다루는 test는 추가되지 않았습니다.
Exploitability 측면에서는, ViewClass::createUninitialized에 전달된 allocation length가 element-copy loop가 가정하는 값과 어긋나야 memory-safety 문제로 이어질 수 있습니다. 해당 loop는 제공된 context 밖에 있습니다. 게다가 새로 추가된 두 test는 patch 이전 코드에서도 통과할 것으로 보입니다. Proxy 기반 tainted slot은 patch 전후 모두 전체 [[Get]] 경로를 거쳤기 때문입니다.
범위를 좁힌 데 따른 부차적인 결과도 있습니다. Proxy가 아닌 producer에서 나온 unset-but-tainted slot은 이제 length 0으로 처리됩니다. property가 존재하는데도 slot을 unset으로 남기는 producer가 있다고 가정할 수 있습니다. 이 경우 이전에는 실제로 읽던 length가 0으로 바뀔 가능성이 있습니다.
여기서 걸려 있는 경계는 내부적인 것입니다. PropertySlot 조회가 보내는 taint 신호와, 이를 받아 복구 방식을 결정하는 native builtin 사이의 계약에 해당합니다. cross-origin이나 sandbox 경계는 관련되지 않습니다. 따라서 length를 어디서 가져오는지에 대한 spec 일관성 또는 관찰 가능성의 차이 정도가 예상되는 결과일 가능성이 높습니다.
Insight: "결과가 불완전하다"는 거친 bit는 사용하는 쪽에 복구가 필요하다는 사실만 알려줄 뿐, 방법까지 알려주지는 않습니다. 복구 방식은 opacity의 구체적인 출처를 기준으로 결정되어야 합니다. 추가된 prototype chain test가 확인하는 것도 이 지점입니다. 즉 더 좁아진 slotBase() 기준이 receiver가 아닌 조상 위치의 Proxy를, revoked Proxy까지 포함해 여전히 잡아내는지를 검증합니다.
Audit directions
- 하나의 flag, 여러 producer. 하나의 taint 또는 opacity flag를 여러 객체 종류가 설정하는데, 사용하는 쪽은 이를 한 종류만 식별하는 것처럼 다루는 패턴입니다. 지켜져야 할 invariant는 opaque 결과에 대해 선택한 복구 경로가 해당 flag의 모든 producer에서 유효해야 한다는 것입니다. Narrow: JavaScriptCore/runtime에서
isTaintedByOpaqueObject()를 사용하는 지점을 검색합니다(예:PropertySlot::getPureResult, 다른 builtin과 IC helper). 그리고 각 지점마다setIsTaintedByOpaqueObject()를 호출하는getOwnPropertySlot구현을 모두 나열합니다(ProxyObject, JSModuleNamespaceObject 및 그 외). Wider:PropertySlot과PutPropertySlot의 다른 집계 bit(cacheability,disableCaching, custom-accessor flag)도 대상입니다. producer가 하나라고 가정하는 사용 지점이 있는지 살펴볼 필요가 있습니다. Widest: 이질적인 "unknown" 출처를 하나의 bit로 합치는 모든 엔진이나 분석기가 해당됩니다. V8의 interceptor 기반 조회, SpiderMonkey resolve hook, 정적 분석기의 taint tracking 등이 그 예입니다. 판별 신호는 집계 flag로 분기하면서, 본문에서 한 producer에게만 올바른 연산(관찰 가능한 Get, 재조회, cache 우회)을 수행하는 코드입니다. - Allocation length와 fill length를 한 번의 read에서 얻는지. array-like length를 한 번 계산한 뒤, 새로 allocate한 uninitialized 저장소를 채우는 native builtin을 점검합니다. invariant는 allocation에 쓰인 원소 개수와 fill에 쓰인 개수가 갈라지는 경로 없이 하나의 read에서 나와야 한다는 것입니다. Narrow:
constructGenericTypedArrayViewWithArguments의 나머지 부분,TypedArray.from,%TypedArray%.prototype.set의 array-like 경로부터 시작합니다.createUninitialized결과에 기록되는 모든 index가 동일한length지역 변수로 제한되는지 확인해야 합니다. Wider:Array.from,Function.prototype.apply의 인자 전개,fillArgList계열 helper가 대상입니다. 이들은 length를 pure inquiry와 관찰 가능한 Get 중 어느 쪽에서든 가져올 수 있습니다. Widest: double-fetch / TOCTOU 계열에 해당합니다. kernel의copy_from_userdouble-fetch 버그와 같은 형태입니다. 한 번의 조회로 buffer 크기를 정하고 다른 조회로 복사하는 FFI 계층도 마찬가지입니다. 판별 신호는 한 함수 안에 "length"를 만드는 표현식이 두 개 이상 있고, 그중 하나 이상이 사용자 코드를 실행하거나 다른 출처를 사용할 수 있는 경우입니다. - 실제 Get 없이 기본값으로 처리되는 tainted-but-unset 조회. tainted-but-unset 조회 결과를 실제
[[Get]]대신 기본값(0, undefined, absent)으로 바꾸는 사용 지점을 점검합니다. invariant는 producer가 "unset"이 "응답 보류"가 아니라 "property 없음"을 뜻한다고 보장할 때만 기본값이 안전하다는 것입니다. Narrow: Proxy가 아닌setIsTaintedByOpaqueObject()호출 지점을 하나씩 확인합니다(JSModuleNamespaceObject::getOwnPropertySlotCommon부터 시작). property가 존재하는데도VMInquiry에서 slot을 unset으로 남길 수 있는지 살펴봐야 합니다. 그런 경우가 있다면 이제constructGenericTypedArrayViewWithArguments가 length를 0으로 계산하게 될 수 있습니다. Wider: named-property getter나 lazy하게 materialize되는 property를 가진 WebCore binding이 대상입니다. 이런 곳에서는 inquiry가 정당하게 응답을 거부할 수 있습니다. Widest: pure query와 effectful read가 분리된 모든 구조가 해당됩니다. Python의hasattr와__getattr__, getter를 건너뛰는 reflection API처럼 "응답 없음"과 "property 없음"이 쉽게 혼동되는 경우입니다. 판별 신호는 opaque producer 경우를 먼저 배제하지 않고 기본값으로 매핑하는isUnset()check입니다.