← All reports

[6] `OpaqueJSClass` stores `parentClass` without retaining it

MediumJavaScriptCore C APIUAF

2a24a9a

Medium 등급이고, 누가 이 경로에 도달할 수 있느냐가 영향 범위를 제한합니다. JSC C API인 만큼 trigger는 embedder가 JSClassDefinition에 parent class를 넘긴 뒤 자신의 handle을 release하는 상황이며, 웹 콘텐츠에서 닿는 경로는 아닙니다. 두 줄 옆에 있는 형제 필드는 reference를 제대로 획득하는데, 바로 이 비대칭 덕분에 점검 과정에서 문제가 명확하게 드러납니다.

refcount 기반 handle을 외부에 넘기는 C API에는 하나의 ownership 계약이 따라붙습니다. 호출자가 건넨 handle을 나중에 쓰려고 보관하는 객체라면, 자기 몫의 reference를 직접 획득해야 합니다. 호출자는 언제든 자신의 reference를 release할 권리가 있기 때문입니다. OpaqueJSClass는 embedder가 JSClassCreate로 생성하는 JSClassRef에 해당하며, JavaScriptCore의 context 독립적인 class descriptor입니다. 여기에는 callback 함수 포인터, static value 및 function 테이블, 그리고 다른 두 class로의 링크가 담깁니다. 이 두 링크인 prototypeClass와 parentClass는 JSClassRef.h에서 모두 평범한 OpaqueJSClass*로 선언되어 있습니다. 그리고 class가 embedder의 C++ 계층을 그대로 반영하는 JS prototype chain을 구성하는 시점에 둘 다 역참조됩니다.

관전 포인트: embedder와 이를 점검하는 쪽에 해당하는 이야기입니다. JSClassDefinition.parentClass로 class 계층을 구성한 뒤 parent handle을 release하는 애플리케이션이라면, child 쪽에는 dangling pointer가 남게 됩니다. 그리고 다음 번 prototype 구체화 시점에 그 pointer를 따라가게 됩니다.

생성자는 형제 관계인 두 class pointer를 서로 다르게 다루고 있었습니다. 먼저 prototypeClass = JSClassRetain(protoClass) 쪽은 strong reference를 획득합니다. 반면 parentClass는 member-initializer list에서 호출자의 definition struct 값을 그대로 복사해 올 뿐이며 — : parentClass(definition->parentClass) — refcount는 건드리지 않았습니다. 소멸자 역시 여기에 맞춰 prototypeClass만 release했습니다. 이번 패치는 두 멤버를 대칭으로 맞췄습니다. 생성 시점에 parentClass에 대해서도 reference를 획득하고, 소멸 시점에 release하도록 변경되었습니다. 또한 parent class를 생성하고, 한 번도 사용하지 않은 채 embedder의 handle을 release한 다음 child를 동작시키는 테스트가 추가되었습니다.

  parent = JSClassCreate(&parentDef)     parent refcount: 1
  childDef.parentClass = parent
  child  = JSClassCreate(&childDef)      parent refcount: 1   ← no retain taken
                                           (prototypeClass would have gone to 2)
  JSClassRelease(parent)                 parent refcount: 0 ──► deleted
  ...
  child->prototype(globalObject)
    └─ if (parentClass)                  non-null, freed
         └─ parentClass->prototype(...)  ← reads prototypeClass, then via
                                           contextData(): m_staticValues,
                                           m_staticFunctions

같은 refcount 타입의 형제 멤버 둘 중 하나만 생성자에서 획득하고 소멸자에서 release하며, 나머지 하나는 raw로 보관한 패턴.

이 코드가 있는 위치. OpaqueJSClass는 native embedder와 JS 엔진 사이의 경계에 위치합니다. 여기서 embedder란 Cocoa 애플리케이션, GLib의 JSCClass 계층, WebKit 자체 테스트 harness 등을 가리킵니다. 웹 콘텐츠의 DOM이나 JIT 경로에 놓인 코드는 아닙니다. JSClassCreate가 JSClassDefinition struct로부터 하나를 구성해 embedder에게 reference를 전달하고, 이후 JSCallbackObject가 이 descriptor를 사용해 JS 객체를 생성합니다. 이렇게 생성된 객체의 prototype chain은 embedder의 C++ class 계층을 그대로 반영합니다.

역할이 다른 두 개의 class 링크. prototypeClass는 static function과 value를 instance가 아니라 prototype 객체 쪽에 설치할 class를 지정합니다. 한편 parentClass는 현재 class가 파생된 상위 class를 가리키며, child에서 조회가 실패하면 embedder 계층을 따라 위로 올라가게 됩니다. prototype을 구성할 때는 두 링크 모두 따라가는데, 둘의 차이는 무엇이 어디에 설치되는지에서 갈릴 뿐 lifetime과는 무관합니다.

C API의 refcount 처리. JSClassRef.h를 보면 OpaqueJSClass는 ThreadSafeRefCounted<OpaqueJSClass>를 상속받고 있으며, JSClassCreate는 count가 1인 handle을 반환합니다. embedder가 사용하는 증가/감소 API는 각각 JSClassRetain과 JSClassRelease입니다. 계약 자체는 이런 종류의 API에서 흔히 볼 수 있는 형태입니다. 객체를 생성한 쪽이 reference 하나를 소유하고 원하는 시점에 release할 수 있으므로, 그 release 이후에도 객체가 살아 있기를 원하는 쪽은 자기 reference를 직접 들고 있어야 합니다.

context data와 주소 재사용. OpaqueJSClassContextData는 const RefPtr<OpaqueJSClass> m_class를 보관하며, 그 이유는 헤더 주석에 적혀 있습니다. VM이 class의 주소를 key로 context data를 캐시하고 있는 동안에는 해당 class가 파괴되어서는 안 된다는 것입니다. "another class is created at the same address" 상황이 발생하면 낡은 캐시 항목이 그대로 매칭되기 때문입니다. 이 RefPtr 덕분에, 살아 있는 context에서 한 번이라도 구체화된 class는 계속 유지됩니다.

버그 유형은 refcount 획득 누락으로 인한 use-after-free입니다. 다만 이 버그가 리뷰를 통과한 이유는 그 구체적인 형태에 있습니다. 같은 타입의 형제 멤버 두 개가 토큰 네 개 간격으로 초기화되면서, ownership만 비대칭으로 어긋나 있었습니다. 빠져 있던 invariant는 앞서 설명한 평범한 C API ownership 계약이며, 수정 방향은 두 멤버를 서로 일치시키는 것입니다.

역참조가 즉시 일어나지 않고 나중으로 미뤄진다는 점이 실수 지점과 crash 지점 사이의 간격을 벌립니다. OpaqueJSClass::prototype(JSGlobalObject*)는 child의 prototype을 구성한 다음 if (parentClass) { if (JSObject* parentPrototype = parentClass->prototype(globalObject)) ... }를 수행합니다. 이때 이미 해제된 객체를 타고 재귀가 이어지면서 prototypeClass 필드를 읽고, contextData()를 거쳐 m_staticValues와 m_staticFunctions 해시 맵까지 접근하게 됩니다. JSClassRelease(parent)와 이 호출 사이에서는 해제된 allocation을 건드리는 코드가 전혀 없습니다. 그래서 free와 use 사이의 window는 embedder가 다음으로 prototype을 구체화하는 시점에 의해서만 결정됩니다.

해제가 정확히 언제 발생하는지를 가르는 세부 조건이 하나 있으며, 새로 추가된 테스트도 여기에 맞춰 설계되었습니다. OpaqueJSClassContextData가 const RefPtr<OpaqueJSClass> m_class를 들고 있기 때문에, 살아 있는 context에서 이미 구체화된 class는 embedder가 자기 handle을 어떻게 다루든 계속 유지됩니다. parent가 실제로 해제되는 경우는, 살아 있는 VM에서 그에 대한 context data가 한 번도 생성되지 않았을 때뿐입니다. 테스트가 인코딩한 순서가 정확히 이것입니다. parent를 생성하고, 사용하지 않은 채 release한 다음, child를 사용합니다. 이를 재현하려는 쪽도 같은 절차를 지켜야 합니다. parent를 먼저 건드리면 버그가 드러나지 않습니다.

나머지 절반은 context data 주석이 경고하던 주소 재사용 위험입니다. 해제된 parent allocation이 회수되면, 그 주석이 막으려던 조건이 그대로 되살아납니다. 즉 해제된 주소에 새 class가 생성되고, 그 주소를 key로 남아 있던 낡은 context data 캐시 항목이 매칭되는 상황입니다.

exploitability를 제한하는 요인은 메커니즘이 아니라 도달 가능성 쪽입니다. trigger는 웹 콘텐츠가 아닙니다. embedder가 JSClassDefinition.parentClass로 parent를 넘기고, child의 prototype이 구체화되기 전에 자기 handle을 release해야 성립합니다. 따라서 공격자 위치는 "native embedder 코드를 제어하거나 그에 영향을 줄 수 있는 상태", 혹은 "ownership을 잘못 다룬 애플리케이션을 점검하는 상태"가 됩니다. 이 위치를 전제로 하면, 얻을 수 있는 primitive는 해제된 ThreadSafeRefCounted 객체에 대한 역참조이며, 이어서 그 객체의 pointer 멤버와 해시 맵을 읽게 됩니다. 재할당 window는 전적으로 embedder의 allocation 패턴에 달려 있습니다.

embedder가 채우도록 문서화되어 있는 필드에서 JS C API의 ownership 계약이 약해진 사례입니다. 결과적으로 embedder가 코드를 올바르게 작성하더라도 dangling pointer가 남게 됩니다.