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.
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:
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.
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.
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.