I haven’t seen this trick in the wild (and couldn’t find any references) and I’m dumbfounded as to why I didn’t notice it before. I knew and used this feature a lot, but assumed that the underlying breakpoint was only set when the option was enabled (assumptions, assumptions…tss tss tss).
The story starts with an upgrade to macOS 15.4. Given Apple’s recent software quality issues, it comes as no surprise that this update broke some custom debugger-related code I was using. The same code worked without problems in all previous macOS versions, so something is broken in the new release (it is!).
While trying to debug the issue, I revisited a bug that I had previously identified in lldbinit after pushing some updates.
The feature in question is stop on images loading, which causes the debugger to stop execution whenever an image is linked into the process. This can be useful for stopping before shared libraries are loaded, a trick I’ve used many times. Because I use it so often, I’ve added a feature (bm
command) to lldbinit to stop execution whenever a specific image is loaded, making it faster to identify the image I’m interested in.
Reproducing the bug is quite straightforward:
(lldbinit) enablesolib
[+] Enabled stop on library events trick.
(lldbinit) c
Process 981 resuming
Traceback (most recent call last):
File "/Users/timapple/lldbinit.py", line 5784, in HandleHookStopOnTarget
bpx = target.FindBreakpointByID(bp_id)
File "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Resources/Python/lldb/__init__.py", line 12100, in FindBreakpointByID
return _lldb.SBTarget_FindBreakpointByID(self, break_id)
OverflowError: in method 'SBTarget_FindBreakpointByID', argument 2 of type 'lldb::break_id_t'
Process 981 stopped
* thread #1, stop reason = shared-library-event
frame #0: 0x000000010003c130 dyld`lldb_image_notifier
Target 0: (clownpertino) stopped.
(lldbinit)
The issue is with this block of code, which implements a feature to display breakpoint names:
if stop_reason == lldb.eStopReasonBreakpoint:
if thread.GetStopReasonDataCount() > 0:
# this gives us the breakpoint id
bp_id = thread.GetStopReasonDataAtIndex(0)
# now we can try to locate it
bpx = target.FindBreakpointByID(bp_id)
Let’s examine the values to understand the problem:
(lldbinit) script
Python Interactive Interpreter. To exit, type 'quit()', 'exit()' or Ctrl-D.
>>> lldb.thread.GetStopReasonDataCount()
2
>>> lldb.thread.GetStopReasonDataAtIndex(0)
18446744073709551615
>>> ^D
now exiting InteractiveConsole...
(lldbinit)
Beyond the type mismatch between the code and UI, the value doesn’t appear logical. So, while investigating the LLDB source code, I discovered that it sets internal breakpoints, one of them on lldb_image_notifier
. I also found out an option to display the internal breakpoints, previously unnoticed.
% lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to '/Users/timapple/clownpertino' (x86_64).
(lldb) process launch -s
Process 749 stopped
* thread #1, stop reason = signal SIGSTOP
frame #0: 0x0000000100007d90 dyld`_dyld_start
dyld`_dyld_start:
-> 0x100007d90 <+0>: movq %rsp, %rdi
0x100007d93 <+3>: andq $-0x10, %rsp
0x100007d97 <+7>: movq $0x0, %rbp
0x100007d9e <+14>: pushq $0x0
Target 0: (clownpertino) stopped.
Process 749 launched: '/Users/timapple/clownpertino' (x86_64)
(lldb) breakpoint list -i
Current breakpoints:
Kind: shared-library-event
-1: name = 'lldb_image_notifier', module = dyld, locations = 1, resolved = 1, hit count = 0
-1.1: where = dyld`lldb_image_notifier, address = 0x000000010003c130, resolved, hit count = 0
(lldb)
The takeaway is that LLDB
always sets an internal breakpoint on the image notifier. This makes it a straightforward and obvious way to determine if a debugger has been attached to the process. It really can’t get much simpler than this. :-).
To implement this easily, we just need to find the address of dyld_all_image_infos
. This can be done by manually parsing dyld
symbols after finding its address. Alternatively, an even simpler approach is to use
TASK_DYLD_INFO
, which will provide us with the address of that structure, either locally or remotely (if we have the necessary mach port).
task_dyld_info_data_t info;
mach_msg_type_number_t size = TASK_DYLD_INFO_COUNT;
kern_return_t kret;
kret = task_info(mach_task_self(), TASK_DYLD_INFO, (void*)&info, &size);
if (kret != KERN_SUCCESS) {
printf("Error: task_info failed: %s\n", mach_error_string(kret));
return 0;
}
struct dyld_all_image_infos *ai = (struct dyld_all_image_infos*)info.all_image_info_addr;
printf("dyld base address: 0x%llx\n", (uint64_t)ai->dyldImageLoadAddress);
Given that we’re in the same process space, we can simply read directly from the TASK_DYLD_INFO
pointer to
extract the address of the image notifier. The final step is to dereference this pointer and inspect its contents. If it contains a breakpoint instruction – an INT3
(0xCC) for x86_64 targets or BRK #0
(0xd420000) for ARM64 – then we can be certain that a debugger is attached to our process. Otherwise, everything appears fine, and no debugger is present. Easy peasy!
If we run the PoC on a 15.4 x86_64 host, there is no debugger detected:
% ./clownpertino
dyld version: 17
dyld string: 1284.13
dyld base address: 0x7ff80225f000
dyld base magic: 0xfeedfacf
Notifier address: 0x7ff802298130
Notifier symbol: 0x39130
Notifier content: 0xe5894855
No debugger detected :)
But if we run it under LLDB
, we can see the int3
patched at the notifier address:
% lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to '/Users/timapple/clownpertino' (x86_64).
(lldb) r
Process 728 launched: '/Users/timapple/clownpertino' (x86_64)
dyld version: 17
dyld string: 1284.13
dyld base address: 0x7ff80225f000
dyld base magic: 0xfeedfacf
Notifier address: 0x7ff802298130
Notifier symbol: 0x39130
Notifier content: 0xe58948cc
DEBUGGER DETECTED! Hey Tim Apple, why don't you give me a $1m instead of selling out to Trump?
Process 728 exited with status = 1 (0x00000001)
(lldb)
The same result on a ARM64 15.4 host:
% ./clownpertino
dyld version: 17
dyld string: 1284.13
dyld base address: 0x184f34000
dyld base magic: 0xfeedfacf
Notifier address: 0x184f7e05c
Notifier symbol: 0x4a05c
Notifier content: 0xd65f03c0
No debugger detected :)
% lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to '/Users/timapple/clownpertino' (arm64).
(lldb) r
Process 25191 launched: '/Users/timapple/clownpertino' (arm64)
dyld version: 17
dyld string: 1284.13
dyld base address: 0x184f34000
dyld base magic: 0xfeedfacf
Notifier address: 0x184f7e05c
Notifier symbol: 0x4a05c
Notifier content: 0xd4200000
DEBUGGER DETECTED! Hey Tim Apple, why don't you give me a $1m instead of selling out to Trump?
Process 25191 exited with status = 1 (0x00000001)
(lldb)
And testing against Sonoma 14.7.1 x86_64:
% ./clownpertino
dyld version: 17
dyld string: 1165.3
dyld base address: 0x7ff81b4e2000
dyld base magic: 0xfeedfacf
Notifier address: 0x7ff81b51e6c0
Notifier symbol: 0x3c6c0
Notifier content: 0xe5894855
No debugger detected :)
% lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to '/Users/timapple/clownpertino' (x86_64).
(lldb) r
Process 818 launched: '/Users/timapple/clownpertino' (x86_64)
dyld version: 17
dyld string: 1165.3
dyld base address: 0x7ff81b4e2000
dyld base magic: 0xfeedfacf
Notifier address: 0x7ff81b51e6c0
Notifier symbol: 0x3c6c0
Notifier content: 0xe58948cc
DEBUGGER DETECTED! Hey Tim Apple, why don't you give me a $1m instead of selling out to Trump?
Process 818 exited with status = 1 (0x00000001)
(lldb)
This works for any recent dyld
versions (I believe 17+) which implement a single lldb_image_notifier
. Older dyld
versions implement it in a different way. If we look at the source code for dyld 551.4, available in High Sierra 10.13.6:
// @ src/glue.c
void _dyld_debugger_notification(enum dyld_notify_mode mode, unsigned long count, uint64_t machHeaders[])
{
// Do nothing. This exists for the debugger to set a break point on to see what images have been loaded or unloaded.
}
extern "C" void _dyld_debugger_notification(enum dyld_notify_mode mode, unsigned long count, uint64_t machHeaders[]);
// @ src/dyld_debugger.cpp
static void gdb_image_notifier(enum dyld_image_mode mode, uint32_t infoCount, const dyld_image_info info[])
{
uint64_t machHeaders[infoCount];
for (uint32_t i=0; i < infoCount; ++i) {
machHeaders[i] = (uintptr_t)(info[i].imageLoadAddress);
}
switch ( mode ) {
case dyld_image_adding:
_dyld_debugger_notification(dyld_notify_adding, infoCount, machHeaders);
break;
case dyld_image_removing:
_dyld_debugger_notification(dyld_notify_removing, infoCount, machHeaders);
break;
default:
break;
}
// do nothing
// gdb sets a break point here to catch notifications
(...)
}
Which one does lldb internally breakpoints?
(lldbinit) break list -i
Current breakpoints:
Kind: shared-library-event
-1: address = dyld[0x0000000000010076], locations = 1, resolved = 1, hit count = 0
-1.1: where = dyld`_dyld_debugger_notification, address = 0x0000000100013076, resolved, hit count = 0
If we run the proof-of-concept (PoC), we obtain an incorrect address via TASK_DYLD_INFO
:
$ ./clownpertino
dyld version: 15
dyld string: 551.5
dyld base address: 0x105636000
dyld base magic: 0xfeedfacf
Notifier address: 0x1056457ce
Notifier symbol: 0xf7ce
Notifier content: 0xe5894855
No debugger detected :)
$ lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to './clownpertino' (x86_64).
(lldb) r
Process 21010 launched: './clownpertino' (x86_64)
dyld version: 15
dyld string: 551.5
dyld base address: 0x100003000
dyld base magic: 0xfeedfacf
Notifier address: 0x1000127ce
Notifier symbol: 0xf7ce
Notifier content: 0xe5894855
No debugger detected :)
Process 21010 exited with status = 0 (0x00000000)
(lldb)
We need to examine the dyld symbols to understand which one this is:
$ nm /usr/lib/dyld | grep notif
000000000000f4e4 t __Z9notifyGDB17dyld_image_statesjPK15dyld_image_info
000000000000f7ce t __ZL18gdb_image_notifier15dyld_image_modejPK15dyld_image_info
0000000000001b16 t __ZN4dyld12notifyKernelERK11ImageLoaderb
00000000000054da t __ZN4dyld22notifyKernelAboutImageEPK12macho_headerPKc
0000000000009218 t __ZN4dyldL11notifyBatchE17dyld_image_statesb
0000000000002001 t __ZN4dyldL12notifySingleE17dyld_image_statesPK11ImageLoaderPNS1_21InitializerTimingListE
0000000000004836 t __ZN4dyldL18notifyBatchPartialE17dyld_image_statesbPFPKcS0_jPK15dyld_image_infoEbb
0000000000008efb t __ZN4dyldL20notifyMonitoringDyldEbjjPK15dyld_image_info
0000000000012c2e t __ZNK11ImageLoader10notifyObjCEv
000000000001e7d0 t __ZNK16ImageLoaderMachO10notifyObjCEv
0000000000010076 T __dyld_debugger_notification
000000000000eb98 t __dyld_objc_notify_register
0000000000025c88 t _coresymbolication_load_notifier
0000000000025d38 t _coresymbolication_unload_notifier
Given that dyld base address is 0x100003000
and the reported address is 0x1000127ce
, we can calculate the corresponding symbol: 0x1000127ce - 0x100003000 = 0xf7ce
. This points to __ZL18gdb_image_notifier15dyld_image_modejPK15dyld_image_info
, a C++ symbol which demangles to _gdb_image_notifier(dyld_image_mode, unsigned int, dyld_image_info const*)
.
Modifying the code to manually apply this offset, in case a breakpoint is not detected at the expected notifier address (address not shown in the output):
$ lldb ./clownpertino
(lldb) target create "./clownpertino"
Current executable set to './clownpertino' (x86_64).
(lldb) r
Process 21653 launched: './clownpertino' (x86_64)
dyld version: 15
dyld string: 551.5
dyld base address: 0x100003000
dyld base magic: 0xfeedfacf
Notifier address: 0x1000127ce
Notifier symbol: 0xf7ce
Notifier content: 0xe5894855
DEBUGGER DETECTED! Hey Tim Apple, why don't you give me a $1m instead of selling out to Trump?
Process 21653 exited with status = 1 (0x00000001)
(lldb)
This means that older LLDB versions set breakpoints in a less interesting function (because it only contains information about the Mach-O header instead of struct dyld_image_info
) so we need to discover and use that function to detect LLDB, instead of using TASK_DYLD_INFO
.
This provides a simple yet effective means of detecting whether a process is running under LLDB.
PoC code available here.
Have fun,
fG!