[6] `OpaqueJSClass` stores `parentClass` without retaining it
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를 따라가게 됩니다.
Patch Details
생성자는 형제 관계인 두 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로 보관한 패턴.
Background
이 코드가 있는 위치.
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는 계속 유지됩니다.
Analysis
버그 유형은 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가 남게 됩니다.
Audit directions
- 형제 멤버 사이의 ownership 비대칭. 같은 refcount 타입의 pointer 두 개를 보관하면서 한쪽만 reference를 획득하는 경우, 실수는 생성자 한 곳에만 드러나고 실제 사용 지점에서는 전혀 보이지 않습니다. C API descriptor 타입이라면 생성자의 initializer list와 소멸자의 release 목록을 나란히 비교해서 일대일 대응이 성립하는지 확인할 필요가 있습니다. 코드 리뷰에서 잡아낼 신호는 이렇습니다. member-initializer list에서 어떤 타입의 필드 하나는
Retain/ref호출로 초기화되는데, 바로 옆에 있는 같은 타입의 필드는 파라미터 struct에서 그대로 복사되는 형태입니다. - 나중에 쓰려고 보관하는 호출자 제공 handle. 호출자의 definition struct에서 pointer를 복사해 오는 C API는 그 호출자의 lifetime을 그대로 물려받습니다. 문제는 그 lifetime이 피호출자가 제어할 수 있는 대상이 아니라는 점입니다.
JSClassDefinition을 다루는 나머지 코드, 그리고 이에 대응하는 GLibJSCClass계층을 점검해 볼 필요가 있습니다. embedder가 소유한 struct에서 복사된 뒤 생성 호출 이후까지 raw pointer로 보관되는 필드가 대상입니다. - 객체 주소를 key로 삼는 캐시.
OpaqueJSClassContextData주석은 위험을 직접적으로 지목합니다. 주소를 key로 남아 있던 낡은 항목이, 같은 주소에 새로 할당된 다른 객체와 매칭된다는 것입니다. pointer 값을 key로 쓰는 map이 있다면 무엇이 그 key를 살아 있게 유지하는지 확인하고, "항목은 살아 있는데 key는 해제될 수 있는" 상황을 누수가 아니라 type confusion의 전제 조건으로 취급할 필요가 있습니다.