CVE-2026-16362
Overview
Background
- `RTCEncodedFrameBase`
- Gecko DOM object backing WebRTC Encoded Transform frames, exposing an encoded audio/video frame’s payload to script as an
ArrayBuffer. - `JS::NewArrayBufferWithUserOwnedContents`
- SpiderMonkey API that creates a JS
ArrayBufferaliasing caller-owned memory that the JS engine neither copies nor frees. - `JS::NewArrayBufferWithContents`
- SpiderMonkey API that creates a JS
ArrayBuffertaking ownership of an engine-arena allocation, tying the buffer’s lifetime to the JS object. - Use-after-free (UAF)
- A memory-safety bug where memory is accessed after it has been freed, here through a dangling
ArrayBufferbacking pointer.
Root Cause Analysis
The RTCEncodedFrameBase constructor built the script-visible mData ArrayBuffer with JS::NewArrayBufferWithUserOwnedContents, so the buffer aliased the raw bytes owned by the underlying frame (mState.mFrame->GetData().data()) rather than owning a copy. This violated the invariant that a JS ArrayBuffer handed to content must outlive, or independently own, its backing store: the frame’s data could be released while script still held a live reference to the ArrayBuffer, leaving a dangling backing pointer. The scattered DetachData() calls (in the cycle-collection unlink, in SetData, and elsewhere) attempted to manually keep the alias and the frame in sync, but could not cover every path by which script retained the buffer after the frame’s memory went away.
The fix copies the frame bytes into a JS-arena allocation (js_pod_arena_malloc + memcpy) and hands that copy to JS::NewArrayBufferWithContents, so the ArrayBuffer owns its own memory and its lifetime is governed entirely by the JS engine. Because mData is now self-owning, the fragile manual DetachData() bookkeeping is removed, and Size() reads the length from the buffer itself (GetArrayBufferByteLength(mData)) rather than dereferencing the possibly-gone frame.
ArrayBuffer that merely aliased frame-owned memory via JS::NewArrayBufferWithUserOwnedContents, decoupling the buffer’s lifetime from the buffer’s backing store; the fix makes the ArrayBuffer own a private copy so its data can never outlive its owner.Attack Path
- Obtain an encoded frame
Via the WebRTC Encoded Transform API, script receives an
RTCEncodedFrameBasewhosemDataArrayBufferaliases the frame’s internal payload. - Retain the `ArrayBuffer`
Script keeps a reference to the
ArrayBuffer(or a typed-array view over it) beyond the point where the transform pipeline is done with the frame. - Trigger backing release
Cause the underlying frame data to be freed (frame consumption/
TakeFrame, detach, or object teardown), leaving theArrayBuffer’s backing pointer dangling. - Access the dangling buffer
Read or write the retained
ArrayBuffer, dereferencing freed memory to disclose or corrupt heap contents.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifdom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp |
modified |
Files Changed
dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp
Audit Directions
- User-owned `ArrayBuffer` contentsAudit every use of
JS::NewArrayBufferWithUserOwnedContents(andJS::NewExternalArrayBuffer) to confirm the aliased memory strictly outlives every reference script can hold to the buffer, or convert it to an owning copy. - Lifetime-coupled aliasesFlag DOM objects that expose internal buffers to JS while retaining a separate owner (
mFrame, backing spans), especially those relying on manual detach/sync calls across teardown paths to prevent dangling views. - Length read from a second sourceReview size/length accessors that read from a field other than the exposed buffer itself; a length derived from a separately-freed object is both a correctness and a UAF hazard.
Patch
diff --git a/dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp b/dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp
index cd8b8f64713..930d2b2788f 100644
--- a/dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp
+++ b/dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp
@@ -19,8 +19,6 @@ namespace mozilla::dom {
NS_IMPL_CYCLE_COLLECTION_CLASS(RTCEncodedFrameBase)
NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(RTCEncodedFrameBase)
- using ::ImplCycleCollectionUnlink;
- tmp->DetachData();
NS_IMPL_CYCLE_COLLECTION_UNLINK(mOwner, mGlobal)
NS_IMPL_CYCLE_COLLECTION_UNLINK(mData)
NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
@@ -53,11 +51,13 @@ RTCEncodedFrameBase::RTCEncodedFrameBase(nsIGlobalObject* aGlobal,
mozilla::HoldJSObjects(this);
- if (mState.mFrame->GetData().data()) {
- // Avoid a copy
- mData = JS::NewArrayBufferWithUserOwnedContents(
- jsapi.cx(), mState.mFrame->GetData().size(),
- (void*)(mState.mFrame->GetData().data()));
+ const auto& frame = mState.mFrame->GetData();
+ if (frame.data()) {
+ UniquePtr<void, JS::FreePolicy> data(js_pod_arena_malloc<uint8_t>(
+ js::ArrayBufferContentsArena, frame.size()));
+ memcpy(data.get(), frame.data(), frame.size());
+ mData = JS::NewArrayBufferWithContents(jsapi.cx(), frame.size(),
+ std::move(data));
} else {
mData = JS::NewArrayBuffer(jsapi.cx(), 0);
}
@@ -70,7 +70,6 @@ RTCEncodedFrameState::RTCEncodedFrameState(
RTCEncodedFrameBase::~RTCEncodedFrameBase() {
DetachData();
- mData = nullptr;
mozilla::DropJSObjects(this);
}
@@ -98,7 +97,6 @@ unsigned long RTCEncodedFrameBase::Timestamp() const {
}
void RTCEncodedFrameBase::SetData(const ArrayBuffer& aData) {
- DetachData();
mData.set(aData.Obj());
if (mState.mFrame) {
aData.ProcessData([&](const Span<uint8_t>& aData, JS::AutoCheckCannotGC&&) {
@@ -122,10 +120,7 @@ RTCEncodedFrameBase::TakeFrame() {
}
size_t RTCEncodedFrameBase::Size() const {
- if (!mState.mFrame) {
- return 0;
- }
- return mState.mFrame->GetData().size();
+ return GetArrayBufferByteLength(mData);
}
RTCEncodedFrameState::~RTCEncodedFrameState() = default;