← All reports

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

MediumJavaScriptCore C APIUAF

2a24a9a

Medium, and bounded by who can reach it: this is the JSC C API, so the trigger is an embedder passing a parent class in a JSClassDefinition and then releasing its own handle — not web content. The sibling field two lines away does take a reference, which is what makes the asymmetry such a clean audit finding.

A C API that hands out refcounted handles carries one ownership contract: an object that keeps a caller-supplied handle for its own later use must take its own reference, because the caller is entitled to release theirs. OpaqueJSClass — the JSClassRef an embedder creates with JSClassCreate — is JavaScriptCore's context-independent class descriptor, holding callback function pointers, static value and function tables, and links to two other classes. Those two links, prototypeClass and parentClass, are both declared as plain OpaqueJSClass* in JSClassRef.h, and both are dereferenced later, when the class materializes a JS prototype chain mirroring the embedder's C++ hierarchy.

The angle: for embedders and their auditors — an application that builds a class hierarchy through JSClassDefinition.parentClass and then releases the parent handle leaves the child holding a dangling pointer that is walked on the next prototype materialization.

The constructor treated its two sibling class pointers differently: prototypeClass = JSClassRetain(protoClass) took a strong reference, while parentClass was copied straight out of the caller's definition struct in the member-initializer list — : parentClass(definition->parentClass) — without touching its refcount. The destructor correspondingly released only prototypeClass. The patch makes the two symmetric, acquiring a reference for parentClass at construction and releasing it at destruction, and adds a test that creates a parent class, never uses it, releases the embedder's handle, and then exercises the 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

Two sibling members of the same refcounted type, one acquired in the constructor and released in the destructor, the other stored raw.

Where this lives. OpaqueJSClass sits at the boundary between native embedders — Cocoa applications, the GLib JSCClass layer, WebKit's own test harnesses — and the JS engine. It is not on the web-content DOM or JIT path. JSClassCreate builds one from a JSClassDefinition struct and hands the embedder a reference; JSCallbackObject later uses the descriptor to build JS objects whose prototype chain mirrors the embedder's C++ class hierarchy.

Two class links with different jobs. prototypeClass names a class whose static functions and values are installed on the prototype object rather than the instance. parentClass names the class this one derives from, so that a lookup that misses on the child continues up the embedder's hierarchy. Both are followed when a prototype is built; the distinction matters for what gets installed where, not for lifetime.

Refcounting in the C API. OpaqueJSClass derives from ThreadSafeRefCounted<OpaqueJSClass>, visible in JSClassRef.h, and JSClassCreate returns a handle with a count of one. JSClassRetain and JSClassRelease are the embedder-facing increment and decrement. The contract is the ordinary one for such APIs: the creator owns one reference and may release it whenever it likes, so anything that wants the object to survive that release must hold its own.

Context data and address reuse. OpaqueJSClassContextData holds a const RefPtr<OpaqueJSClass> m_class, and its header comment explains the reason: a class must not be destroyed while a VM still caches context data keyed on its address, because "another class is created at the same address" would then match the stale cache entry. That RefPtr keeps any class alive once it has been materialized in some live context.

The bug type is a use-after-free caused by a missing refcount acquisition, and the specific shape — ownership asymmetry between two sibling members of the same type, initialized four tokens apart — is why it survived review. The missing invariant is the plain C API ownership contract stated above; the fix is to make the two members agree.

The dereference is lazy rather than immediate, which widens the gap between the mistake and the crash. OpaqueJSClass::prototype(JSGlobalObject*) builds the child's prototype and then does if (parentClass) { if (JSObject* parentPrototype = parentClass->prototype(globalObject)) ... }, recursing through the freed object — reading its prototypeClass field and, via contextData(), its m_staticValues and m_staticFunctions hash maps. Nothing between JSClassRelease(parent) and that call touches the freed allocation, so the window between free and use is bounded only by when the embedder next materializes a prototype.

One detail sharpens exactly when the free occurs, and the new test is built around it. Because OpaqueJSClassContextData holds a const RefPtr<OpaqueJSClass> m_class, a class that has already been materialized in a live context stays alive regardless of what the embedder does with its own handle. The parent is only genuinely freed when it never had context data created for it in a live VM — which is precisely the sequence the test encodes: create the parent, never use it, release it, then use the child. An auditor reproducing this needs the same discipline; touching the parent first hides the bug.

The address-reuse hazard the context-data comment warns about is the other half of the story. A reclaimed parent allocation re-opens exactly the condition that comment exists to prevent — a new class constructed at the freed address, matching a stale context-data cache entry keyed on that address.

Exploitability is constrained by reachability rather than by mechanism. The trigger is not web content: it requires an embedder that passes a parent through JSClassDefinition.parentClass and releases its own handle before the child's prototype is materialized, so the attacker position is "controls or influences native embedder code" or "audits an application that got its ownership wrong". Given that position, the primitive is a dereference of a freed ThreadSafeRefCounted object followed by reads of its pointer members and hash maps, with the reclaim window entirely under the embedder's allocation pattern.

This vulnerability weakens the JS C API's ownership contract for a field embedders are documented to fill in, in a way that makes correct embedder code produce a dangling pointer.