Critical CVSS 10 firefox Sandbox Escape 🔧 Commit mapped

Overview

Critical
Severity
10
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactcritical
DescriptionFollowing the recent Chrome sandbox escape (CVE-2025-2783), various Firefox developers identified a similar pattern in our IPC code. A compromised child process could cause the parent process to return an unintentionally powerful handle, leading to a sandbox escape. <br>The original vulnerability was being exploited in the wild. <br>*This only affects Firefox on Windows. Other operating systems are unaffected.*
ComponentIPC
Bug ClassSandbox Escape
Tracker1956398
Fix commit8de17578df66 (firefox) +36/-7
CWECWE-668 (Exposure to wrong sphere)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedAndrew McCreight
Disclosed2025-03-27

Background

`Channel::ChannelImpl::AcceptHandles`
parent-side routine that, for an incoming message carrying handles, reads each uint32_t handleValue from the payload, reconstructs a HANDLE via Uint32ToHandle, and duplicates it out of the sending (child) process into the current (parent) process with DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE.
`Channel::ChannelImpl::TransferHandles`
the sending side that duplicates a local handle into the peer process.
Pseudo-handle
a special Windows HANDLE value that does not name a specific kernel object but is resolved relative to the calling process: GetCurrentProcess() returns (HANDLE)-1, GetCurrentThread() returns (HANDLE)-2, and (per the borrowed Chromium comment) fuzzing found problematic values down to -12. When DuplicateHandle is given a pseudo-handle as the source, it duplicates whatever that pseudo-handle means in the source process context.
`INVALID_HANDLE_VALUE`
the value (HANDLE)-1, which is numerically identical to the GetCurrentProcess() pseudo-handle.

Root Cause Analysis

In AcceptHandles, the parent validated each attacker-supplied handle value only with if (!ipc_handle || ipc_handle == INVALID_HANDLE_VALUE). That check rejects null and the single value -1, but it does not reject the other process pseudo-handles (-2 for the current thread, and other negative sentinels down to -12). The handle value comes from the message payload, i.e. it is fully controlled by the (possibly compromised) child process. When a malicious child placed a pseudo-handle value such as (HANDLE)-2 into a handle-bearing message, the parent passed it directly to ::DuplicateHandle(other_process_, ipc_handle, GetCurrentProcess(), ...). Because other_process_ is the child, DuplicateHandle interpreted the pseudo-handle relative to the child, but the duplication target is the parent (GetCurrentProcess()), and the resulting duplicated handle can refer to a far more powerful object than the IPC protocol intended — the “unintentionally powerful handle” the advisory describes, returned to and usable by the child. This is the same defect pattern as the in-the-wild Chrome sandbox escape CVE-2025-2783: trusting a child-supplied handle value that turns out to be a pseudo-handle.

The patch introduces an IsPseudoHandle(HANDLE) helper (borrowed from Chromium’s base/win/win_util.h) that returns true for handle values in [-12, 0), and a DuplicateRealHandle wrapper that asserts MOZ_RELEASE_ASSERT(!IsPseudoHandle(source_handle)) before calling ::DuplicateHandle. AcceptHandles now rejects an incoming handle with if (!ipc_handle || IsPseudoHandle(ipc_handle)) instead of the narrow == INVALID_HANDLE_VALUE test, so any pseudo-handle from the child is refused at the gate. Both the receive path (AcceptHandles) and the send path (TransferHandles) are switched from raw ::DuplicateHandle to DuplicateRealHandle, so the no-pseudo-handle invariant is enforced (and release-asserted) on every handle duplication across the IPC boundary. The advisory’s SandboxEscape / CWE-668 (“exposure to wrong sphere”) classification is exactly what the diff substantiates: a compromised child steers the parent into duplicating a privileged handle into the wrong sphere (back to the child).

Key insight
The parent duplicated child-supplied handle values after rejecting only null and -1, so a compromised child could send a Windows pseudo-handle (e.g. GetCurrentThread() = -2) and make the parent hand back an unintentionally powerful handle — the fix rejects the whole pseudo-handle range [-12, 0) on every duplication.

Attack Path

  1. Compromised child An attacker who already has code execution in a sandboxed Firefox child process (e.g. a renderer exploited via a content bug) crafts an IPC message that carries a transferred handle.
  2. Smuggle a pseudo-handle Instead of a real object handle, the child places a pseudo-handle value such as (HANDLE)-2 (GetCurrentThread) — or another sentinel in [-12, -1] not equal to INVALID_HANDLE_VALUE — into the serialized handle payload.
  3. Bypass the weak check The parent’s AcceptHandles old guard (== INVALID_HANDLE_VALUE) does not catch these values, so it proceeds to ::DuplicateHandle(other_process_ /*child*/, ipc_handle, GetCurrentProcess() /*parent*/, ...).
  4. Parent duplicates a powerful handle The pseudo-handle resolves in the child’s context to a privileged object (e.g. a thread/process handle) and a usable duplicate is produced, with DUPLICATE_CLOSE_SOURCE semantics, returnable to the child.
  5. Primitive The child obtains a handle to a resource it should never have, escalating out of the sandbox into parent-level capability (sandbox escape).

Impact Assessment

This is a parent (broker) process bug affecting Firefox on Windows only (other OSes have different handle semantics and are unaffected, per the advisory). The attacker precondition is a already-compromised child process — this is a sandbox-escape primitive, not an initial-compromise bug — but given that precondition the impact is critical (CVSS 10.0, scope-changed S:C): the child induces the privileged parent to create and expose an unintentionally powerful handle, which is a classic stepping stone to full parent-process control and thus escape from the content sandbox. The diff concretely substantiates the handle-validation gap and its fix; the exact privileged object obtained and the subsequent escalation are implementation details not enumerated by the diff, but the pattern (and its CVE-2025-2783 analogue, exploited in the wild in Chrome) makes the sandbox-escape consequence well founded. No user interaction is required (UI:N).

Changed Functions

FunctionChangeNotes
for
ipc/chromium/src/chrome/common/ipc_channel_win.cc
modified
if
ipc/chromium/src/chrome/common/ipc_channel_win.cc
modified

Files Changed

  • ipc/chromium/src/chrome/common/ipc_channel_win.cc

Audit Directions

  • Every `::DuplicateHandle` call site
    grep the Windows IPC/child-management code for remaining raw ::DuplicateHandle uses and confirm each either goes through DuplicateRealHandle or has a documented, legitimate pseudo-handle use.
  • Other child-supplied OS resource identifiers
    review any IPC path where the parent acts on a value controlled by a child (handle values, PIDs, section/mapping names, section offsets) with validation that only blocks a couple of sentinels rather than enforcing a positive-allowlist of expected values.
  • Non-Windows handle transfer analogues
    although this CVE is Windows-only, check the file-descriptor transfer logic on other platforms for conceptually similar “trust the child’s descriptor without classifying it” assumptions.
  • CVE-2025-2783 pattern sweep
    more broadly, audit broker/parent code that duplicates handles on behalf of less-privileged processes for the specific pseudo-handle-as-source footgun that both this bug and the Chrome original share.
diff --git a/ipc/chromium/src/chrome/common/ipc_channel_win.cc b/ipc/chromium/src/chrome/common/ipc_channel_win.cc
index 316776b8ac1..7aca70046df 100644
--- a/ipc/chromium/src/chrome/common/ipc_channel_win.cc
+++ b/ipc/chromium/src/chrome/common/ipc_channel_win.cc
@@ -29,6 +29,34 @@
 
 using namespace mozilla::ipc;
 
+namespace {
+
+// This logic is borrowed from Chromium's `base/win/win_util.h`. It allows us
+// to distinguish pseudo-handle values, such as returned by GetCurrentProcess()
+// (-1), GetCurrentThread() (-2), and potentially more. The code there claims
+// that fuzzers have found issues up until -12 with DuplicateHandle.
+//
+// https://source.chromium.org/chromium/chromium/src/+/36dbbf38697dd1e23ef8944bb9e57f6e0b3d41ec:base/win/win_util.h
+inline bool IsPseudoHandle(HANDLE handle) {
+  auto handleValue = static_cast<int32_t>(reinterpret_cast<uintptr_t>(handle));
+  return -12 <= handleValue && handleValue < 0;
+}
+
+// A real handle is a handle that is not a pseudo-handle. Always preferably use
+// this variant over ::DuplicateHandle. Only use stock ::DuplicateHandle if you
+// explicitly need the ability to duplicate a pseudo-handle.
+inline bool DuplicateRealHandle(HANDLE source_process, HANDLE source_handle,
+                                HANDLE target_process, LPHANDLE target_handle,
+                                DWORD desired_access, BOOL inherit_handle,
+                                DWORD options) {
+  MOZ_RELEASE_ASSERT(!IsPseudoHandle(source_handle));
+  return static_cast<bool>(::DuplicateHandle(
+      source_process, source_handle, target_process, target_handle,
+      desired_access, inherit_handle, options));
+}
+
+}  // namespace
+
 namespace IPC {
 //------------------------------------------------------------------------------
 
@@ -594,7 +622,7 @@ bool Channel::ChannelImpl::AcceptHandles(Message& msg) {
   nsTArray<mozilla::UniqueFileHandle> handles(num_handles);
   for (uint32_t handleValue : payload) {
     HANDLE ipc_handle = Uint32ToHandle(handleValue);
-    if (!ipc_handle || ipc_handle == INVALID_HANDLE_VALUE) {
+    if (!ipc_handle || IsPseudoHandle(ipc_handle)) {
       CHROMIUM_LOG(ERROR)
           << "Attempt to accept invalid or null handle from process "
           << other_pid_ << " for message " << msg.name() << " in AcceptHandles";
@@ -612,9 +640,10 @@ bool Channel::ChannelImpl::AcceptHandles(Message& msg) {
         CHROMIUM_LOG(ERROR) << "other_process_ is invalid in AcceptHandles";
         return false;
       }
-      if (!::DuplicateHandle(other_process_, ipc_handle, GetCurrentProcess(),
-                             getter_Transfers(local_handle), 0, FALSE,
-                             DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE)) {
+      if (!DuplicateRealHandle(
+              other_process_, ipc_handle, GetCurrentProcess(),
+              getter_Transfers(local_handle), 0, FALSE,
+              DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE)) {
         DWORD err = GetLastError();
         // Don't log out a scary looking error if this failed due to the target
         // process terminating.
@@ -687,9 +716,9 @@ bool Channel::ChannelImpl::TransferHandles(Message& msg) {
         CHROMIUM_LOG(ERROR) << "other_process_ is invalid in TransferHandles";
         return false;
       }
-      if (!::DuplicateHandle(GetCurrentProcess(), local_handle.get(),
-                             other_process_, &ipc_handle, 0, FALSE,
-                             DUPLICATE_SAME_ACCESS)) {
+      if (!DuplicateRealHandle(GetCurrentProcess(), local_handle.get(),
+                               other_process_, &ipc_handle, 0, FALSE,
+                               DUPLICATE_SAME_ACCESS)) {
         DWORD err = GetLastError();
         // Don't log out a scary looking error if this failed due to the target
         // process terminating.
Loading diff…