← All reports

[6] TypedArray constructor length read keyed on opaque-taint bit instead of Proxy identity

LowJavaScriptCore runtime TypedArray constructorsTypeConfusion

240fe1f

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의 length binding을 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 결과를 그대로 유지하며, 이 결과는 length binding을 읽지 않습니다.

Source/JavaScriptCore/runtime/JSGenericTypedArrayViewConstructorInlines.h

- if (lengthSlot.isTaintedByOpaqueObject()) {
- JSValue lengthValue = object->get(globalObject, vm.propertyNames->length);
- RETURN_IF_EXCEPTION(scope, nullptr);
- length = lengthValue.toLength(globalObject);
- RETURN_IF_EXCEPTION(scope, nullptr);
- } else if (lengthSlot.isUnset())
- length = 0;
- else {
- JSValue value = lengthSlot.getValue(globalObject, vm.propertyNames->length);
...
+ JSObject* lengthBase = lengthSlot.slotBase();
+ JSValue lengthValue = jsUndefined();
+ if (lengthSlot.isTaintedByOpaqueObject() && lengthBase && lengthBase->type() == ProxyObjectType)
+ lengthValue = object->get(globalObject, vm.propertyNames->length);
+ else if (!lengthSlot.isUnset())
+ lengthValue = lengthSlot.getValue(globalObject, vm.propertyNames->length);
+ RETURN_IF_EXCEPTION(scope, nullptr);
+ length = lengthValue.toLength(globalObject);
+ RETURN_IF_EXCEPTION(scope, nullptr);
}
 
ViewClass* result = ViewClass::createUninitialized(globalObject, structure, length);

JSTests/stress/typedarray-constructor-proxy-array-like.js

+let derived = Object.create(protoProxy);
+let fromDerived = new Float64Array(derived);
+if (protoLog.join(",") !== "Symbol(Symbol.iterator),length,0,1")
+ throw new Error("proto gets " + protoLog.join(","));
...
+let revokedDerived = Object.create(revoked.proxy);
+revokedDerived[Symbol.iterator] = undefined;
+revoked.revoke();
+shouldThrow(() => new Float64Array(revokedDerived), "TypeError");

이번 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를, 마치 한 종류만 식별하는 것처럼 사용한 패턴.

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을 통해 수행됩니다.

이번 버그는 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까지 포함해 여전히 잡아내는지를 검증합니다.