Published on 03 November 2025 by Jing Huang.
This article presents a technical analysis of runtime Mach-O decryption techniques for iOS application security research, based on a binary dumper implementation in C originally developed by Stefan Esser in 2011 and later enhanced by Conrad Kramer. This allows us to bypass FairPlay DRM protection during penetration tests through runtime memory access when encrypted binaries exist in their decrypted state.
Apple’s FairPlay DRM system encrypts specific parts of the __TEXT segment1 within Mach-O executable files of iOS applications distributed through the App Store. This
encryption is designed to protect the intellectual property of developers and prevent unauthorized modification or
distribution of applications.
When an encrypted application launches, iOS automatically decrypts the __TEXT segment into memory for
execution. This means the decrypted segment is accessible through runtime introspection.
There are various ways to inject a dynamic library into a running process and thus access its memory, name a few:
DYLD_INSERT_LIBRARIES, instructing the dynamic loader to load libraries into a process at launch
time.lldb to the target process and execute dlopen().In this article, we will only cover the DYLD_INSERT_LIBRARIES approach, which is the simplest and
most robust at cost of flexibility. Encrypted segments are located using LC_ENCRYPTION_INFO.
Recent iOS hardening prevents non-system processes from spawning containerized apps, while entitlement workarounds that try to fake a platform binary will break framework loading2. To inject dynamic libraries into running process, we can use MobileSubstrate instead; however the idea is essentially the same.
So here is the implementation in C, for decrypting protected images, with explanations. The full implementation is also available on GitHub. Explanation will be divided into sections, and only basic knowledge on the C programming language is assumed.
|#include <dlfcn.h>|#include <fcntl.h>|#include <mach-o/dyld.h>|#include <mach-o/fat.h>5 |#include <mach-o/loader.h>|#include <stdarg.h>|#include <stdint.h>|#include <stdio.h>|#include <stdlib.h>10 |#include <string.h>|#include <unistd.h>
Structure containing information of dynamic linking is provided by dlfcn.h, and low-level file input
and output is provided by fcntl.h. The three headers under mach-o folder provides dynamic
linker interface, fat (universal) binary related information, and Mach-O binary format structures respectively.
|static inline uint32_t bswap32 (uint32_t value) {|return ((value & 0xFF000000) >> 24) | ((value & 0x00FF0000) >> 8) ||((value & 0x0000FF00) << 8) | ((value & 0x000000FF) << 24);|}
This function performs a byte swap, reversing the byte order of a 32-bit integer, which is used for conversion
between big-endian and little-endian representations. This is used in handling fat (universal) binaries when the
magic constant has value FAT_CIGAM3, that all multi-byte values require
swapping on little-endian devices.
|__attribute__ ((constructor)) static void dump () {|_dyld_register_func_for_add_image (&queue);|}
The constructor attribute tells the compiler that this function will get called right after the
shared library get loaded, adding a hook to execute the callback function queue every time a new Mach-O
image gets loaded into the memory.
This will ensure that every encrypted image including frameworks that is been used in the target application is
being processed by function queue, which acts exactly like a queue.
|static void queue (const struct mach_header *mh, intptr_t slide) {|Dl_info ii;|dladdr (mh, &ii);|decrypt (ii.dli_fname, mh);5 |}
What this function does is using the Mach-O header being passed in to get the actual path to that image using
dlfcn.h provided dladdr, then hand it together with the header, to the function
decrypt which does all the heavy job.
|int in_fd, out_fd;|long pos_tmp;|char buffer[4096], in_path[4096], out_path[4096], *str_tmp;|off_t off_cid, off_rest, off_read;5 |uint32_t off_tmp = 0, int_tmp = 0, zero = 0;||struct fat_arch *fa;|struct fat_header *fh;|struct load_command *lc;10 |struct encryption_info_command *eic;
Receiving the path to the image, and the header, this is where the actual decrypting is done. But first we declare
the variables that we will be using. This function itself receives *path and *mh, from the
queue.
|switch (mh->magic) {|case MH_MAGIC:|lc = (struct load_command *) ((unsigned char *) mh +|sizeof (struct mach_header));5 |break;|case MH_MAGIC_64:|lc = (struct load_command *) ((unsigned char *) mh +|sizeof (struct mach_header_64));|break;10 |default:|_exit (1);|}
Here we find the start of the load command area using offset of the memory pointer to header and its size, since load command is located right after the header. When the magic number in header is unknown, we escape.
|if (realpath (path, in_path) == NULL)|strlcpy (in_path, path, sizeof (in_path));|str_tmp = strrchr (in_path, '/');
Stores the base name of the target by chopping everything before the last path-separator / in
str_cmp.
|for (int i = 0; i < mh->ncmds; i++) {|if (lc->cmd == LC_ENCRYPTION_INFO || lc->cmd == LC_ENCRYPTION_INFO_64) {|eic = (struct encryption_info_command *) lc;|if (eic->cryptid == 0)5 |break;|// to be continued|}|lc = (struct load_command *) ((unsigned char *) lc + lc->cmdsize);|}
Starting at the beginning of the load commands, we iterate through each of the ncms commands, until
we reach the encryption info command, then cast it to encryption_info_command for further processing. It
stops early when there is nothing to decrypt.
|off_cid = ((unsigned char *) &eic->cryptid - (unsigned char *) mh);|in_fd = open (in_path, O_RDONLY);|int_tmp = read (in_fd, (void *) buffer, sizeof (buffer));|fh = (struct fat_header *) buffer;
We first store the offset of the cryptid relative to the image in the memory so later we can
overwrite it with the value zero, standing for not encrypted image. Then we open the target image, and read in 4kb
data (which is much larger than any possible fat header) to reinterpret cast
it to
fat_header.
|switch (fh->magic) {|case FAT_CIGAM:|fa = (struct fat_arch *) (fh + 1);|for (int i = 0; i < bswap32 (fh->nfat_arch); i++, fa++) {5 |if (mh->cputype == bswap32 (fa->cputype) &&|mh->cpusubtype == bswap32 (fa->cpusubtype)) {|off_tmp = bswap32 (fa->offset);|break;|}10 |}|break;|case MH_MAGIC:|case MH_MAGIC_64:|break;15 |default:|_exit (1);|}
Here we store in off_tmp the offset to correct slice when we have a fat image that contains multiple
slices of images. Note that all values are stored in bit-endian and require swapping.
|strlcpy (out_path, getenv ("HOME"), sizeof (out_path));|strlcat (out_path, "/tmp/", sizeof (out_path));|strlcat (out_path, str_tmp + 1, sizeof (out_path));|strlcat (out_path, ".d", sizeof (out_path));5 |out_fd = open (out_path, O_RDWR | O_CREAT | O_TRUNC, 0644);
Create the output file in the application container root, which makes sandbox happy.
|int_tmp = off_tmp + eic->cryptoff;|off_rest = lseek (in_fd, 0, SEEK_END) - int_tmp - eic->cryptsize;|lseek (in_fd, 0, SEEK_SET);
Put the absolute file offset to the start of the encrypted region into int_tmp, and the number of
bytes remained after the encrypted region into off_rest. Then we reset the read position back to the
start of file.
|while (int_tmp > 0) {|off_read = int_tmp > sizeof (buffer) ? sizeof (buffer) : int_tmp;|pos_tmp = read (in_fd, buffer, off_read);|pos_tmp = write (out_fd, buffer, off_read);5 |int_tmp -= off_read;|}
Copy the first not encrypted part, that is, everything before the encrypted region which offset is known.
|pos_tmp =|write (out_fd, (unsigned char *) mh + eic->cryptoff, eic->cryptsize);
Then we write the decrypted in-memory segment after the header.
|int_tmp = off_rest;|lseek (in_fd, eic->cryptsize, SEEK_CUR);
Get the iterator variable ready, and set the file position pointer to the start of the final not encrypted region.
|while (int_tmp > 0) {|off_read = int_tmp > sizeof (buffer) ? sizeof (buffer) : int_tmp;|pos_tmp = read (in_fd, buffer, off_read);|pos_tmp = write (out_fd, buffer, off_read);5 |int_tmp -= off_read;|}
Finally, copy the not encrypted remainder to the decrypted image.
|if (off_cid) {|off_cid += off_tmp;|if (lseek (out_fd, off_cid, SEEK_SET) != off_cid |||write (out_fd, &zero, 4) != 4)5 |_exit (1);|}
To make this an unencrypted image, the last step is to overwrite the cryptid, indicating that this
image is no longer encrypted. Afterward, the file descriptors should be closed, though modern operating system
typically handle this.
The following command compiles it into a arm64 iOS dynamic library, with Xcode installed.
|xcrun --sdk iphoneos clang -arch arm64 -dynamiclib -Os \|-isysroot `xcrun --sdk iphoneos --show-sdk-path` \|-F`xcrun --sdk iphoneos --show-sdk-path`/System/Library/Frameworks \|fairplay.c -o fairplay.dylib
This dynamic library should be at least ad-hoc signed, using either codesign -fs - or ldid
-S, then placed in /Library/MobileSubstrate/DynamicLibraries/, with the following
filter4.
|<?xml version="1.0" encoding="UTF-8"?>|<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">||<plist version="1.0">5 |<dict>|<key>Filter</key>|<dict>|<key>Bundles</key>|<array>10 |<string>com.YoStarJP.Arknights</string>|</array>|</dict>|</dict>|</plist>
After launching com.YoStarJP.Arknights, this library will be injected, dumping the decrypted
image.