Packing malware in Rosetta 2

TL;DR

If you want the slides, I've appended them to the end of this page. You can use them to follow along with the talk that was listed at SummerCon's recording of the talk.

I'm also keen on internships or knowing more folks in security - feel free to reach out!

Introduction

Rosetta 2 is a translation layer that allows macOS to run Mach-O x86_64 binaries on Apple Silicon. To make it short and sweet, it is a transpiler tool that uses static compilation when it can to convert code and cache it away via oahd_helper and for branching out to indirect calls, it will hand it over to JIT compilation.

It will be phased out in the next major MacOS update - with the new tools being the GPTK (Game Porting ToolKit) which packages Windows for game review/porting and then other items of Linux, like VZVirtualMachine. Who knows how it will be architected, but this is a nice little tool that has been used to smooth out the transition to M-series chips!

Understanding how Rosetta 2 ends up giving us AARCH64

Ahead-of-time

Like I mentioned above, we want to first understand how Rosetta 2 leverages both ahead-of-time and just-in-time compilation to keep MachO zippy.

Using the oahd-helper daemon, we first check any exec call that is made against a given x86_64 target, calculate a hash and then check it against a known cache at /var/db/oah/. If we find it, no problem.

If it isn't found, we instead try to perform as much one-for-one x86_64 to AARCH64 translation as possible, and then cache it in a given exec.aot file. We also do this for any required frameworks that are needed by the executable.

This is done for all binaries required, and we put it under a directory.

If we can't translate a given block, we'll stub a branch that we can figure out later and resolve at runtime - without going into the major differences, this is similar-ish to how dyld works in how we use dyld to resolve dynamic library calls but 'make space' in the LAZY_SYMBOL_POINTERS section of a given MachO file.

This is going to be verbose as we have many differences from ARM64 to x86, and we want to keep state. Why? We need to also append the original x86 instrucitons to a processes' memory so we can refer to them later when needing to find instructions for JIT translation.

We'll also want to be quick for first launch- there's details on this on dougallj's blog I have referred to before.

To show the AOT section:

Just-in-time

Cool, so we've got the aot file cached and kept away in a location where SIP is gonna protect it from the nasties. Since we have stubbed any indirect calls, when we end up running the program, we'll need to resolve any stubs at runtime.

This is for extern calls, indirect branching available in x86_64 and anything else we can't really deterministically resolve. runtime from Rosetta 2 will lookup the original instructions we mentioned before and walks down a red-black binary tree of these to resolve new instructions and hand it back to the TEXT segment responsible for executing code on your Macbook!

And the JIT section:

What else is in Rosetta 2?

We have other cool goodies to help with mimicking all sorts of characteristics that isn't on AARCH64:

  • A Rosetta Return Stack is allocated for RIP-relative addressing, including
  • Rosetta Thread Info is allocated for thread-local storage
  • other gubbin' that isn't something I looked at, you can always mmap!

We aren't going to see this since LLDB and debugging shims the same x86 so we can't see the resulting instructions that get run. It helps the developer that worked on their Intel Mac apps to still debug on Apple Silicon, neato!

The AOT shared_cache and runtime will work together to review and resolve all function starts and code blocks to combat the lack of heavy optimisation - this isn't investigated with regards to of exec primitives. There's a variety of protections available in malloc to prevent cross-pollution of x86 and AARCH64 memory.

Gamehacking over Rosetta 2

Okay, so I already did the bulk of this in a prior talk at Ruxmon Melbourne in 2025. This was initially inspired by Jai Vermas blog on AssaultCube hacks on Intel MacOS, which I thank for the inspo!

My updated version for Apple Silicon's AssaultCube hack over Rosetta 2 available at my prior blog post in 2025 about making a game hack - so I won't go over the specifics on how we leverage it, but in short:

  1. We got an offset from the original instance of a health value,
  2. We then write a cheat to walk the series of pointers to get the player health value,
  3. Finally, we dereference the pointer to get the value and set it to some godmode value.

Setting up the game cheat

We can manually add this given library using DYLD_INSERT_LIBRARIES environment variable, which is similar to LD_PRELOAD on Linux - we use it to ask dyld to load our library before any other library.

Why don't you see DYLD_INSERT_LIBRARIES used much? Well, dyld also checks for sections of a library for codesigning - if the library is not signed, dyld will not load it.

But Rosetta 2 allows us to run unsigned binaries, which includes loading unsigned libraries into a otherwise signed process.

It doesn't check this stuff despite needing these sections to be present!

Apple Silicon security paradigms in Rosetta 2

On iOS and for many contexts on MacOS, codesigning is required now we are on Apple Silicon.

Keep a pin on LC_LOAD_DYLIB/LC_LOAD_WEAK_DYLIB for later... WINK!!!

In this situation, we would require valid signatures on all libraries and executables, whether ad-hoc or formal to run. DYLD will check this and all Mach-O files carry their signatures as a special section.

These tools are part of a system that negotiates around when Gatekeeper would fire on MacOS, where a user would need to additionally verify that this executable is OK to run.

From Apple's documentation on Unsigned x86_64 code on Rosetta 2 against how it typically checks for signing:

Rosetta 2's loading of AOT sections don't map these load command tables from the original x86, as it's not needed.

Attack Surfaces in Rosetta 2

This means we can understand how Rosetta 2 transpiles code and what is avoided to understand rudimentary attack surfaces:

  1. Injecting code via dlopen and libraries,
  2. Exploitation of AOT cache characteristics with function stubbing,
  3. Understanding how Rosetta 2 avoids a bunch of contexts Gatekeeper usually fires,
  4. Architecture preferences uname from the kernel to define x86/ARM execution paths,
    • This includes checks on MAP_JIT and other items pending further research for shellcode.
  5. Ways Rosetta 2 sets up your Mac to maintain x86_64 features (malloc flags, registers)

AOT Checksums

As we know, we review via oahd to check for AOT caches on exec for a given Intel executable. Project Champollion details the characteristics of how these checksums are created, and are relatively abritrary if we keep in mind that we should ensure the relative virtual address the x86_64 code section is kept the same along with relative paths from the main executable.

From the decompilation of oahd (cred to Koh Nakagawa):

Further to this, using otool we get a Mach-O load command, which... yes, loads the AOT files into memory.

Code like you're on x86_64 (for malware)

Child processes via posix_spawn() can retain the preferred architecture setting. Undocumented registers from Apple are used to ensure further compatibility with Intel is kept, such as ACTLR_EL1, which flips the first bit to enable total-store ordering, meaning any store operations str are ordered as they would be on x86_64, and doesn't use any tricky ARM weakly-ordered memory model - more on ARM memory ordering here!

This is a novel feature of Apple Silicon chips, which is documented from Asahi Linux.

Examples of this in North Korean malwares

Google Cloud Security's Threat Intelligence went over Rosetta 2 artifacts and instusions refering to PoolRAT, which pulled down a unified binary and deliberately executes the x86_64 verion to use this character of Rosetta 2, which caches part of the translated binary in the /var/db/oah directory, but we know it doesn't handle everything.

Interestingly, they note that Unified Logs also don't catch attribution for PoolRAT by default since oahd omits names, and a custom profile needed to be set up to catch it. Oopsie!!!!!

So really, we can understand this characteristic to force JIT transition. (void)(void*) and dynamic execution means we'll need dynamic analysis to see what Rosetta 2 spits out - and in the case of PoolRAT, it removes itself - so you don't get the whole corpus of code that ended up executing with just the .aot files.

Setting up a sneakier attack on Rosetta 2

Finding a target on a dependent library

Instead of using DYLD_INSERT_LIBRARIES to enforce an arbitrary library to run ahead, we should try to leverage how dyld works by resolving these external library calls at the LAZY_SYMBOL_POINTERS section. This is known as swizzling.

For AssaultCube, it uses the SDL2 (Simple DirectMedia Layer) library to render graphics and handle input events. This is within the AssaultCube app bundle.

To confirm, we can open up AssaultCube and see that SDL_Init is in the LAZY_SYMBOL_POINTERS section of the file:

Finally, we can check that for the full name what Framework is loaded in LLDB:

Understanding time of resolution and use

To doubly confirm, we'll want to understand where this function is resolved and used in the actual process - which will define when our swizzle will run. This is important as we could use values given in the process at runtime in a real-world scenario, or we want to simply run our swizzle as early as possible.

In this case, the documentation for SDL_Init will have it run to start the SDL subsystem, and we can confirm this via a breakpoint!

We can also confirm this via static analysis on the AOT file that translates the call to SDL_Init along with it's relative path @rpath/SDL2.framework/...

Slay... looks like this is a viable candidate to swizzle!

Swizzling with your own function pointers

Swizzling is where we set up our own function pointers to replace the expected, original pointer. In this instance, we'll want to run it and then ensure state is kept to hand it over to the intended call.

In short, here's is some dummy code:

// clang -arch x86_64 -dynamiclib interpose.c -o
#include <stdio.h>
// re-use it wherever this way!!! clang will fix it up
#define DYLD_INTERPOSE(_replacement, _replacee)
__attribute__((used)) static struct {
const void* replacement;
const void* replacee;
} _interpose_##_replacee __attribute__ ((section("__DATA, __interpose"))) = {
(const void*) (unsigned long) &_replacement,
(const void*) (unsigned long) &_replacee
};
int my_printf(const char *format, ...)
{
int ret = printf("Hello from interpose... uh oh!!!\n");
return ret;
}
DYLD_INTERPOSE(my_printf,printf);
DYLD_INSERT_LIBRARIES=./interpose.dylib ./hello
Hello from interpose... uh oh!!!

As you can see here, there is a __DATA, __interpose section in the binary that holds the interpose information that is used for interposing function calls, which we then enforce by telling clang to not strip the code (as it will, being empty after analysis).

Using this information, we can create out own dylib to append to AssaultCube - truncating it so we just focus on the swizzle and ensuring state is kept to hand it over to start the game:

Then we can use other features of dyld and dynamic linking to leverage constuctors, destructors and that we ourselves resolve SDL_Init to hijack the resolved pointer, and enforce any calls go to us first:

Ensuring it's cosy in our applications directory with all the other libraries

We can then use lief or clang to instead of using DYLD_INSERT_LIBRARIES, we can use DylibCommands to either enforce strong (required to continue) or weak (optional to load, no worries if it's not there!) loading of our swizzling dylib. This uses install_name to specify the path of the dylib to load, which is relative to executable path... remember that image path from LLDB!

clang -arch x86_64 \
-shared \
-install_name @executable_path/../Frameworks/evil.dylib \
-F assaultcube.app/Contents/Frameworks \
-Wl, -ld_classic \
-framework SDL2 \
-o assaultcube.app/Contents/Frameworks/evil.dylib \
evil.c
import lief
SDL2 = "assaultcube.app/Contents/Frameworks/SDL2.framework/Versions/A/SDL"
binary = lief.parse(SDL2)
binary.add(lief.MachO.DylibCommand.weak_lib(
"@executable_path/../Frameworks/evil.dylib"
))
binary.remove_signature()
binary.write(SDL2)

OK FINE here's the calc

Conclusion and afterthoughts

Rosetta 2 is an amazing little gizmo when it comes to how it is absolutely fast-as-hell in terms of using ahead-of-time translation to remove bulk for just-in-time translation to do only what is nessecary. This flow allows for less work to be done when your legacy app is actually running, and ensures all the verbosity that we would need to ensure state for JIT is fast.

Using this method of two-step transpilation and preferring verbosity over conciseness or optimisation, additional features can be added since we can always lookup state from one architecture to another.

We get all these cute tweaks for memory ordering, RIP-relative addressing and more; that has made Rosetta 2 pretty stable and performant in such a way that game emulation is quite smooth considering - I love playing Guild Wars 2 on the damn thing!

Hopefully this blog and talk shows some problems of having a predicate where not all code translated in one spot creates some interesting problems.

Just-in-time translation is still something that can run a lot more independently than one initially assumes by enforcing indirect calls along with an assumption that unsigned code is ok.

To clear up the post a bit... interposing is an intended function of how many external calls can be shimmed to allow for performance, stability and modularity in many programs. However, this with Rosetta 2 is clearly a great mix for making simple malwares that are harder to detect that you'd think!

I'm currently working on more Rosetta 2 research and am interested in MacOS/iOS targets - I am available for internships in lieu of my full-time studies for now - if you're performing research on the same stuff, I'd love to talk!

Slides from SummerCon

Below are the original slides from SummerCon 2026 in Brooklyn, NYC.

Your browser doesn't support embedded PDFs. Download the PDF instead.