SMAppService LaunchDaemon: is privilege drop followed by same-PID exec supported before Mach service check-in?

I’m designing a least-privilege system LaunchDaemon registered with SMAppService, and I’d like to clarify whether the following architecture is supported by public macOS contracts.

The LaunchDaemon declares a MachServices entry. Its steady-state service must run as a dedicated non-root account and later creates an NSXPCListener for that Mach service.

We currently launch the daemon directly using UserName, GroupName, and InitGroups=false.

However, InitGroups=false does not appear to guarantee that the resulting process supplementary-group list is limited to the service’s intended group. In testing, the daemon received a supplementary group outside our accepted set.

We therefore do not want to depend on incidental inherited launch-time group state.

We are considering this alternative:

  1. launchd starts a small, fixed, code-signed bootstrap executable as root.

  2. The bootstrap reads the target UID/GID from an existing protected root-owned binding record.

  3. It establishes an exact credential state using public BSD APIs, conceptually:

    setgroups(...) setgid(...) setuid(...)

  4. It verifies the resulting non-root credentials.

  5. It creates no XPC listener or storage connection while privileged.

  6. Without forking, it permanently replaces itself using execve() (or possibly POSIX_SPAWN_SETEXEC) with another fixed, separately signed executable in the same bundle.

  7. That non-root executable independently validates its security state and then creates NSXPCListener(machServiceName:) for the Mach service declared by the original LaunchDaemon job.

The bootstrap would not remain as a privileged parent or supervisor.

My main questions are:

  1. Is a same-PID exec after permanent UID/GID/supplementary-group reduction supported for an SMAppService system LaunchDaemon before it checks in to its declared Mach service?

  2. Does the exec-replaced process retain the launchd/bootstrap context required for NSXPCListener(machServiceName:) to check in to that Mach service?

  3. If so, what execution context must be preserved across exec (for example bootstrap context, environment, file descriptors, or Mach rights)?

  4. Is there a documented way to preserve only the context required for the LaunchDaemon/Mach-service relationship without carrying unintended root-derived capabilities into the non-root executable?

  5. Would SMAppService.unregister() / normal launchd termination continue to treat the exec-replaced process as the same LaunchDaemon job?

If this topology is not supported, is there an Apple-supported way to establish an exact supplementary-group set before a non-root SMAppService LaunchDaemon begins handling its Mach service?

The goal is to avoid relying on undocumented launchd behavior, incidental supplementary groups, private APIs, or a long-lived privileged helper.

I’m specifically looking for the supported contract here rather than whether this happens to work on a particular macOS release.

Answered by DTS Engineer in 904623022
The reason I was considering two executables was purely to minimize the amount of application code that ever runs as root.

OK. That’s a worthy goal, but I think it engenders too much risk in this case.

One alternative would be to move this code into a shared library that the main tool loads after dropping its privileges. That makes it easy to audit that the main tool can’t do any XPC before that transition.

Is that single-process pattern supported for an SMAppService LaunchDaemon with a declared MachServices entry?

It’s hard to guarantee that. Your plan is reasonable enough — specifically, Mach IPC is capability based, so it won’t be affected by changes to your BSD privileges — but it’s still weird, and weird stuff is more likely to break that standard stuff. However, I think it represents a reasonable compromise between your security goals and compatibility risk.


Regardless of what else you do, I recommend that you file an enhancement request against launchd for a property that causes it to do this work instead of your. And please post that bug number, just for the record

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

macOS really prefers folks to combine fork and exec*. Doing a fork without an exec* is an ongoing source of weird issues. Doing an exec* without a fork is less problematic, but still concerning.

I’m not sure I understand why you need two executables here. What’s the problem with running the setgroup and so on in the startup sequence of the main executable?

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks, Quinn.

The reason I was considering two executables was purely to minimize the amount of application code that ever runs as root. The long-lived daemon is intended to have a strict invariant that all XPC and storage work occurs only after it has permanently transitioned to the dedicated non-root account.

If using a single executable is the preferred design, I’m happy to simplify it that way. The startup sequence would then be roughly:

launchd starts the daemon as root setgroups(exactGroupSet) setgid(targetGID) setuid(targetUID) verify real/effective UID/GID and supplementary groups only then create NSXPCListener(machServiceName:)

No XPC listener, storage connection, network activity, or other privileged resource would be opened before the credential transition.

Is that single-process pattern supported for an SMAppService LaunchDaemon with a declared MachServices entry? In particular, after the process permanently changes its UID/GID/group list but does not exec, can it still check in to its original launchd Mach service using NSXPCListener(machServiceName:)?

If so, that would remove my reason for needing the exec boundary.

I’m away from my development environment for the next few days, so I may not be able to test a suggested approach immediately, but I wanted to clarify the architectural question while the context is fresh.

Thanks again.

Accepted Answer
The reason I was considering two executables was purely to minimize the amount of application code that ever runs as root.

OK. That’s a worthy goal, but I think it engenders too much risk in this case.

One alternative would be to move this code into a shared library that the main tool loads after dropping its privileges. That makes it easy to audit that the main tool can’t do any XPC before that transition.

Is that single-process pattern supported for an SMAppService LaunchDaemon with a declared MachServices entry?

It’s hard to guarantee that. Your plan is reasonable enough — specifically, Mach IPC is capability based, so it won’t be affected by changes to your BSD privileges — but it’s still weird, and weird stuff is more likely to break that standard stuff. However, I think it represents a reasonable compromise between your security goals and compatibility risk.


Regardless of what else you do, I recommend that you file an enhancement request against launchd for a property that causes it to do this work instead of your. And please post that bug number, just for the record

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks, Quinn. I filed the launchd enhancement request as FB24726121. Thanks again for the guidance.

SMAppService LaunchDaemon: is privilege drop followed by same-PID exec supported before Mach service check-in?
 
 
Q