When working on software, it is sometimes necessary to debug very early in a program’s lifecycle (e.g., before the first line of main() when the dynamic loader is running). Often times this is accomplished by loading the target binary into a debugger and running it directly. However there are other times where this is not possible, such as when an intermediate process is responsible for starting the program. This page describes a technique for macOS to attach a debugger in such circumstances.

The Strategy

In order to attach to a program early in its lifecycle, but where we cannot start the program under the debugger, we can make use of “destructive” DTrace operations to pause the process with a SIGSTOP. In order to do this, we need two elements to fingerprint it with a DTrace probe:

  1. A way to identify the process without a PID
  2. A system call issued early in the process’s lifecycle at or before where we want to attach

DTrace

DTrace is a kernel-side tracing and debugging tool that ships on macOS. In order to be usable, one must reboot into recovery mode and disable System Integrity Protection.

By default, DTrace provides a read-only view of memory and it cannot perform any operations that would alter the behavior of the system. With the -w option, DTrace will permit destructive operations, including the raising of signals.

The Technique

In order to identify the newly started process in the kernel, we need to be able to fingerprint it. Typically just the execname variable should be sufficient to identify the process, but if not, there are several other built-in variables that could be used to identify it (e.g., uid, ppid, etc.).

The second part of the fingerprint requires a system call made by the process, which can be hooked with a DTrace probe. A handy system call is thread_selfid, which is used to get the current thread ID very early in the program startup when dyld is bootstrapping.

Other system calls could be used to find a later point in time, or ones that take arguments that could help further target the process if execname was not enough to deduce it. As an example, the open system call could be used with copyinstr(arg0) to target processes opening specific files.

With the two elements of the fingerprint, we can write a DTrace probe to raise SIGSTOP, signal 17, when the probe matches:

$ sudo dtrace -w -n 'syscall::thread_selfid:entry /execname == "TextEdit"/ { printf("Target PID: %d", pid); raise(17); }'

The above example will match the thread_selfid system call when we launch TextEdit. The probe prints the PID, to make it easier to attach with lldb, and pauses the program with SIGSTOP. Again, the -w flag is required to allow raising the signal.

After this probe matches, the dtrace command should be terminated to avoid sending further SIGSTOPs, and then lldb -p <pid> can be used to attach the debugger. Once attached, breakpoints can be set at the desired locations before continuing the program’s execution.

A Note on LaunchServices

The debugging technique outlined in this page was developed when debugging a behavior that only occurred when an application was started via LaunchServices. Applications on macOS are typically launched through the Finder or the Dock. When this happens, those GUI processes are not actually responsible for starting the application. Instead, the Finder/Dock sends a message to launchservicesd. In turn, launchservicesd sends a message to launchd to start the process, which it does by posix_spawn()ing a new instance of /usr/libexec/xpcproxy. The new xpcproxy process then receives a message from launchd telling it which process should actually be started. It then proceeds calls posix_spawn() again but with the special POSIX_SPAWN_SETEXEC flag that makes it behave like execve(), rather than creating a new process, and the application is actually started.