Will macOS and iOS merge?

Janury 24, 2024

Our goal at DFF is to reveal any threats on mobile devices, and that requires us to do a fair share of both static and dynamic analysis. The former is simple enough, as you can throw a binary into a variety of disassemblers and decompilers. The latter, however, is challenging - especially in the case of iOS, which - outside of “Research Devices” - does not have an environment hospitable for debugging.

In today’s post, we would like to share a simple, but quite powerful approach - changing the playing field from the challenging iOS to the much more welcoming macOS.

Let’s port iOS binaries to macOS :)

Let’s take a look at the relevant differences between iOS and macOS Mach-O binaries:

Architecture, Platform and SDK:

iOS executables can be built for ARM-based processors, while macOS executables can be built for both Intel-based and the newer ARM-based processors family (i.e. Apple Silicon in all its variants). This information is stored in the header section of the Mach-O, as shown below:

struct mach_header_64 {
	uint32_t	magic;		/* mach magic number identifier */
	cpu_type_t	cputype;	/* cpu specifier */
	cpu_subtype_t	cpusubtype;	/* machine specifier */
	uint32_t	filetype;	/* type of file */
	uint32_t	ncmds;		/* number of load commands */
	uint32_t	sizeofcmds;	/* the size of all the load commands */
	uint32_t	flags;		/* flags */
	uint32_t	reserved;	/* reserved */
};

Machine-specific values are defined for all supported architectures and CPUs in <mach/machine.h> header. 

The platform related information is otherwise stored in a load command named LC_BUILD_VERSION: This replaces the older LC_MACOS/IPHONEOS/TVOS/WATCHOS_VERSION_MIN, since Apple keeps making new “OSes” (most recently, VISION OS), though in fact these are all identical, with minor UI differences and maybe an odd daemon here or there.

struct build_version_command {
    uint32_t	cmd;		/* LC_BUILD_VERSION */
    uint32_t	cmdsize;	/* sizeof(struct build_version_command) plus */
                                /* ntools * sizeof(struct build_tool_version) */
    uint32_t	platform;	/* platform */
    uint32_t	minos;		/* X.Y.Z is encoded in nibbles xxxx.yy.zz */
    uint32_t	sdk;		/* X.Y.Z is encoded in nibbles xxxx.yy.zz */
    uint32_t	ntools;		/* number of tool entries following this */
};

… 

#define PLATFORM_MACOS 1
#define PLATFORM_IOS 2
#define PLATFORM_TVOS 3
#define PLATFORM_WATCHOS 4
#define PLATFORM_BRIDGEOS 5 // Formerly, eOS, presently, dead
#define PLATFORM_MACCATALYST 6 // iOS environment on Mac
... simulators
#define PLATFORM_DRIVERKIT 10
... two hidden ones here - 11, 12, possibly visionOS?
#define PLATFORM_FIRMWARE 13
#define PLATFORM_SEPOS 14

… 

Reference: darwin-xnu/EXTERNAL_HEADERS/mach-o/loader.h

The LC_BUILD_VERSION load command is part of the Mach-O file format and contains details about the version of the binary, including the platform (operating system), build version, and information related to the SDK (Software Development Kit) used for compilation. At one time, this was informatory only, but is now verified by the OS, which will kill the binary on mismatch.

The strategy here is quite simple: 

We are going to map an input Mach-O (taken for instance from an IPSW) to memory, iterate through its Load Commands until we find LC_BUILD_VERSION and tweak it accordingly.

Note that any modification of the header will invalidate the signature. We can discount this, since on macOS unsigned binaries (or linker signed) are allowed - so we can remove the signature, and re-fake-sign in some way.

So we wrote the tool, gave it a try, and ……

the binary still got killed.

Looking at the os_log log (with /usr/bin/log stream) we see:

DFFenders@GZ:Downloads % log stream | grep -i '\"df\"' &  
[1] 48048 48049
DFFenders@GZ:Downloads % ./df
[2]    48057 killed     ./df
2024-01-18 0x8733f    Default     0x0                  0      0    kernel: proc 48057: load code signature error 4 for file "df"
2024-01-18 0x8733f    Default     0x0                  0      0    kernel: exec_mach_imgact: not running binary "df" built against preview arm64e ABI
DFFenders@GZ:Downloads % codesign --remove-signature df                                                                                                                                                                          
DFFenders@GZ:Downloads % ./df
[2]    48076 killed     ./df
2024-01-18 0x873be    Default     0x0                  0      0    kernel: exec_mach_imgact: not running binary "df" built against preview arm64e ABI 

The message is from the kernel, in exec_mach_imgact. The specific check responsible to prevent the binary from running is in the OSX (not iOS) kernel:

#if __has_feature(ptrauth_calls) && defined(XNU_TARGET_OS_OSX)

…

    if ((imgp->ip_origcpusubtype & ~CPU_SUBTYPE_MASK) == CPU_SUBTYPE_ARM64E &&
        CPU_SUBTYPE_ARM64_PTR_AUTH_VERSION(imgp->ip_origcpusubtype) == 0 &&
        !load_result.platform_binary &&
        !bootarg_arm64e_preview_abi) {
        static bool logged_once = false;
        set_proc_name(imgp, p);

        printf("%s: not running binary \"%s\" built against preview arm64e ABI\n", __func__, p->p_name);
        if (!os_atomic_xchg(&logged_once, true, relaxed)) {
            printf("%s: (to allow this, add \"-arm64e_preview_abi\" to boot-args)\n", __func__);
…
	goto badtoolate;
…

#endif /* __has_feature(ptrauth_calls) && defined(XNU_TARGET_OS_OSX) */

Reference: darwin-xnu/bsd/kern/kern_exec.c

arm64e introduced PAC for both mobile and desktop platforms; on macOS, this feature was, at the time, a preview ABI, therefore it requires SIP to be disabled and ‘-arm64e_preview_abi’ boot-arg set.

We can workaround this check by tweaking Mach-O’s header field cpusubtype field so that ip_origcpusubtype does not read 0, therefore allowing execution.

 if (g_targetOS == PLATFORM_MACOS) {
	// The secret sauce: change the PTRAUTH version, since with version 0
	// macOS's XNU kills the binary.
 	mh->cpusubtype = 0x81000002;
	printf("Adjusting CPU subtype to %x\n", mh->cpusubtype);
	}

Back to Tests!

Let’s try our tool against some target that is not shipped as a macOS binary, for instance lsdiagnose. This is one of the command line utilities shipped with stock iOS for the purposes of running under sysdiagnose. iOS has a couple of other such nice utilities (as well as a few interesting daemons which can be subjected to this).

Trying this on the binary from the iOS image…


DFFenders@GZ:Downloads % ./cbv /Volumes/DawnB21B91.D84OS/usr/bin/lsdiagnose \ 
                            to macos 14.3 
Original Build Version:   iOS 17.1.0 SDK: 17
Converted to:             macOS 14.3.0 SDK: 14
Adjusting CPU subtype to 81000002
Output to /tmp/lsdiagnose 

Aaaaaand…..

DFFenders@GZ:Downloads % Downloads % /tmp/lsdiagnose
Checking data integrity...
...done.
Database is seeded.
Status:                     Preferences are loaded.
Seeded System Version:      14.3 (23D5051b)
Seeded Model Code:          J316cAP/MacBookPro18,2
CacheGUID:                  832D6D15-B32D-4670-A7C7-D76A803B4E42
CacheSequenceNum:           61340
Date Initialized:           2023-08-09 15:58 (POSIX 1691589481, 𝛥 5mths 1wk 4days 8hrs 28min 5secs)
xcode-select Version:       2405
Path:                       /var/folders/x7/qd8fmx_n4jd1gmx818dbwwyc0000gn/0/com.apple.LaunchServices.dv/com.apple.LaunchServices-5019-v2.csstore
DB Object:                   { userID = 501, path = '/var/folders/x7/qd8fmx_n4jd1gmx818dbwwyc0000gn/0/com.apple.LaunchServices.dv/com.apple.LaunchServices-5019-v2.csstore' }
DB Bundle:                  /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework (v 1141.1)
Store Object:               <_CSStore 0x1546069d0> { p = 0x102e4c000, gen = 17403022, length = 9666560/9666116/9647608 }
Store Bundle:               /System/Library/PrivateFrameworks/CoreServicesStore.framework (v 1141.1)
0x00000000 00000000:	6264736c 02001618 8e8c0901 447e9300    bdsl········D~··
+++++++++++++++++++++++++++++++ Structure Sizes ++++++++++++++++++++++++++++++++
sizeof(Data):                      100 ( 100 bytes) -----
sizeof(Table):                      80 (  80 bytes) -----
sizeof(Unit):                        8 (   8 bytes) -----
sizeof(IdentifierCache):             4 (   4 bytes) -----
+++++++++++++++++++++++++++++++ Memory by Table ++++++++++++++++++++++++++++++++
:                       1229952 (    1,2 MB) 23532 units (≅52 bytes/unit)
:                      1951145 (      2 MB) 56212 units (≅34 bytes/unit)
ActivityTypeBinding:             32656 (     33 KB) 1 units
Alias:                         1195248 (    1,2 MB) 1318 units (≅906 bytes/unit)
BindableKeyMap:                  65296 (     65 KB) 1 units
… 

It works! 

This means we can now execute - and also debug - any binary from any iOS variant with the comfort of our development environment. This won’t get entitled binaries, but if you use a SIP disabled system for your debugging and reversing (in a VM, we hope!) that’s not a problem.

This technique should work as is on all CLIs and most daemons (/usr/libexec), since those rely on dylibs present both in the iOS flavors and macOS. Vision OS isn’t out yet, but it should be workable there just as well. It should also work the other way around - porting from macOS to your favorite iOS variant, though naturally there you’d need it to be jailbroken.

Lastly, we leave it as a thought exercise for the interested reader if this will work on actual Apps (that is, library dependencies on iOS specific frameworks, which can’t be found on macOS). 

TL;DR


Craig Federighi vehemently denies macOS and iOS will merge.

In some ways, they already have.


Ah, yes. And the source:

#include <errno.h>
#include <fcntl.h>  // O_RD..
#include <libgen.h> // basename
#include <mach-o/fat.h>
#include <mach-o/loader.h>
#include <stdio.h>    // printf, etc..
#include <stdlib.h>   // exit, etc
#include <string.h>   // strcmp..
#include <sys/mman.h> // mmap..
#include <sys/stat.h> // stat..
#include <unistd.h>   // access, etc

// Standalone version of changeBuildVersion.c
// to compile: gcc ....c -o cbv

/// Colors
#define BOLD "\033[1;1m"
#define UNDERLINE "\e[4m"
#define RED "\033[0;31m"
#define M0 "\e[30m"
#define CYAN "\e[36m"
#define CYAN_INV "\e[46m"
#define M1 "\e[31m"
#define GREY "\e[37m"
#define GREY_INV "\e[47m"
#define M8 "\e[38m"
#define M9 "\e[39m"
#define GREEN "\e[32m"
#define GREEN_BOLD "\e[32;1m"
#define YELLOW_INV "\e[43m"
#define YELLOW "\e[33m"
#define BLUE "\e[34m"
#define BLUE_BOLD "\e[34;1m"
#define PINK_INV "\e[45m"
#define PINK "\e[35m"

#define NORMAL "\e[0;0m"
#define ICON_ERROR "🚫 "
/// End colors

#define VERBOSE_PRINT 1
typedef int verbose_t;

int color() { return 1; }

int g_targetOS = 0;
int g_targetVer = 0;
int g_majVer = 0, g_minVer = 0, g_microVer = 0;

typedef int (*LCCallback_t)(struct mach_header_64 *Mh, int Offset, int Size,
                            int Verbose);

int modVerCallback(struct mach_header_64 *Mh, int Off, int Size, int Verbose) 
{
    struct build_version_command *bvc =
        (struct build_version_command *)((char *)Mh + Off);
    if (bvc->cmd != LC_BUILD_VERSION)
        return 1;

    // jtool -l style listing here`
    uint32_t platform = bvc->platform;
    char *platformStr = NULL;
    uint32_t minos = bvc->minos;
    uint32_t sdk = bvc->sdk;
    // uint32_t ntools = svc->ntools;

#if 0
#define PLATFORM_MACOS 1
#define PLATFORM_IOS 2
#define PLATFORM_TVOS 3
#define PLATFORM_WATCHOS 4
#define PLATFORM_BRIDGEOS 5
#define PLATFORM_MACCATALYST 6
#define PLATFORM_IOSSIMULATOR 7
#define PLATFORM_TVOSSIMULATOR 8
#define PLATFORM_WATCHSIMULATOR 9
#define PLATFORM_DRIVERKIT 10

// from mach-o/loader.h
#endif

    char *platforms[] = {
        "0", "macOS", "iOS", "tvOS", "watchOS", "bridgeOS", "6", "7", "8", "9"
    };
    if (platform <= 10) {
        platformStr = platforms[platform];
    } else {
        platformStr = "unknown! or... what year are we in?!";
    }

    fprintf(stdout, "%-25s %s %d.%d.%d SDK: %d\n",
            "Original Build Version:", platformStr,
            (int)(minos >> 16) & 0x0000FfFF, (int)(minos >> 8) & 0x000000FF,
            (int)(minos) & 0x000000FF, sdk >> 16);

    if (platform <= 10) {
        platformStr = platforms[g_targetOS];
    } else {
        platformStr = "unknown! Please tell J!";
    }

    fprintf(stdout, "%-25s %s %d.%d.%d SDK: %d\n", "Converted to:", platformStr,
            g_majVer, g_minVer, g_microVer, g_majVer);

    bvc->platform = g_targetOS;
    bvc->sdk = g_majVer << 16;
    bvc->minos = (g_majVer << 16) | (g_minVer << 8) | g_microVer;

    return 0;
}

int processLoadCommands(struct mach_header_64 *Mh, int Size, verbose_t Verbose,
                        LCCallback_t Callback) 
{
    // Passing mach_header_64 though at some point by mh->magic
    // I can do 32-bit as well

    int is32 = 0;

    switch (Mh->magic) {
    case MH_MAGIC:
        is32++;
        break;
    case MH_MAGIC_64:
        break;
    case MH_CIGAM: /*setEndianness(1);  */
        is32++;
        break;
    case MH_CIGAM_64: /*setEndianness(1); */
        break;
    default:
        fprintf(stderr, "%s: Not a Mach-O magic (0x%x)\n", __FUNCTION__,
                Mh->magic);
        return 1;
    }

    int ncmds = Mh->ncmds;
    int lc;
    uint32_t offset =
        is32 ? sizeof(struct mach_header)
             : sizeof(struct mach_header_64); // should adjust if 32..
    for (lc = 0; lc < ncmds; lc++) {
        struct load_command *theLC =
            (struct load_command *)((char *)Mh + offset);
        if (offset > Size) {
            fprintf(stderr,
                    "%sERROR: Load command %d (Offset %d) falls outside read "
                    "memory (%d)?!\n",
                    color() ? ICON_ERROR : "", lc, offset, Size);

            return 123;
        }

        if (offset >= Mh->sizeofcmds + sizeof(struct mach_header_64)) {
            fprintf(stderr,
                    "%sERROR: Load command %d (Offset %d) falls outside header "
                    "(%ld)?!\n",
                    color() ? ICON_ERROR : "", lc, offset,
                    Mh->sizeofcmds + sizeof(struct mach_header_64));

            return 234;
        }

        // This is the centerpiece - code here is ripped from J's Machlib
        // (which can support all parsing of Mach-o, a la jtool2/disarm), but
        // for changing the binary version all we need is to get a way to extend
        // LC_BUILD_VERSION, which will be done by the callback.

        if (Callback) {
            Callback(Mh, offset, Size, Verbose);
        }

        offset += theLC->cmdsize;
        // if (Verbose & VERBOSE_PRINT) printf("\n");
    }

    return 0;

}

unsigned char *mapFileToMemory(char *File, uint32_t *Size) 
{
    int fd = open(File, O_RDONLY);
    if (fd < 0) {
        fprintf(stderr, "Unable to open file %s: %s\n", File, strerror(errno));
        return (NULL);
    }

    struct stat stbuf;
    fstat(fd, &stbuf);
    int filesize = stbuf.st_size;
    unsigned char *mmapped =
        (unsigned char *)mmap(NULL,
                              filesize,              // size_t len,
                              PROT_READ,             // int prot,
                              MAP_SHARED | MAP_FILE, // int flags,
                              fd,                    // int fd,
                              0);                    // off_t offset);

    if (mmapped == MAP_FAILED) {
        fprintf(stderr, "Unable to map %s\n", File);
        exit(100);
    }

    // Do a RW mapping:
    unsigned char *mmappedRW =
        (unsigned char *)mmap(NULL, filesize, PROT_READ | PROT_WRITE,
                              MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (mmappedRW == MAP_FAILED) {
        fprintf(stderr, "Unable to create RW for map %s - %s\n", File,
                strerror(errno));
        exit(101);
    }

    memcpy(mmappedRW, mmapped, filesize);
    munmap(mmapped, filesize);

    if (Size)
        *Size = filesize;

    return (mmappedRW);
};

int main(int argc, char **argv) 
{
    // syntax:
    if (argc < 4) {
        fprintf(stderr, "rebuild _binary_  [as] [ios/macos/tvos] "
                        "[versionMin.versionMaj]\n");
        fprintf(stderr, "example: cbv lsmp to ios 16.4\n");

        fprintf(
            stderr,
            "\n\nNote: This invalidates the code signature! This tool will use "
            "'codesign' to a) strip the signature and b) re-sign ad hoc\n");
        exit(1);

    } // argc < 4

    char *fileName = argv[1];

    // Check file is a file... mach-O stuff comes later.
    if (access(fileName, R_OK) != 0) {
        fprintf(stderr, "%s: Can't open file\n", fileName);
        exit(2);
    }

    int targetOSPos = 2;
    if (strcmp(argv[2], "to") == 0) {
        targetOSPos++;
    }

    // Validate target OS:

    // Should be array of OS names mapping to PLATFORM_* consts... but whatever

    if (strcasecmp(argv[targetOSPos], "ios") == 0) {
        g_targetOS = PLATFORM_IOS;
    }
    if (strcasecmp(argv[targetOSPos], "macos") == 0) {
        g_targetOS = PLATFORM_MACOS;
    }
    if (strcasecmp(argv[targetOSPos], "watchos") == 0) {
        g_targetOS = PLATFORM_WATCHOS;
    }
    if (strcasecmp(argv[targetOSPos], "bridgeos") == 0) {
        g_targetOS = PLATFORM_BRIDGEOS;
    }
    if (strcasecmp(argv[targetOSPos], "tvos") == 0) {
        g_targetOS = PLATFORM_TVOS;
    }

    if (g_targetOS == 0) {
        fprintf(stderr,
                "Which target platform do you want to rebuild for? Supported "
                "are iOS, macOS, watchOS, bridgeOS, tvOS - you said '%s'\n",
                argv[targetOSPos]);
        exit(3);
    }

    // Get version

    g_majVer = 0;
    g_minVer = 0;
    g_microVer = 0;

    if (targetOSPos + 1 > argc - 1) {
        fprintf(stderr, "Specify OS version too, please\n");

        exit(1);
    }

    int rc = sscanf(argv[targetOSPos + 1], "%d.%d.%d", &g_majVer, &g_minVer,
                    &g_microVer);

    if (rc == 0) {
        // No conv
        fprintf(stderr, "Version should be specified as major.minor.micro - "
                        "e.g. '16', '16.2', '16.4.0'\n");
        exit(4);
    }

    // Nobody cares about SDK... that will be set to major.

    // Get to work:

    uint32_t filesize;
    unsigned char *mmapped = mapFileToMemory(fileName, &filesize);
    if (!mmapped)
        exit(5);

    if (filesize < sizeof(struct mach_header_64)) {
        // Too small
        fprintf(stderr,
                "File must be this high to qualify as a valid mach-O..\n");
        exit(6);
    }

    struct mach_header_64 *mh = (struct mach_header_64 *)mmapped;

    if ((mh->magic == FAT_MAGIC) || (mh->magic == FAT_CIGAM)) {
        fprintf(stderr, "Not handling fat binaries! go get lipo first\n");
        exit(7);
    }
    if ((mh->magic != MH_MAGIC) && (mh->magic != MH_MAGIC_64)) {
        fprintf(stderr, "Not a Mach-O\n");
        exit(8);
    }

    // If architecture is not ARM, ..
    if (mh->cputype != CPU_TYPE_ARM64) {
        fprintf(stderr, "Mach-O is not an ARM64 or ARM64e - No sense in using "
                        "this tool. Sorry!\n");
        exit(8);
    }

    // Ok. Sanity aside, we can really do this now

    processLoadCommands((void *)mmapped, // struct mach_header_64 *Mh,
                        filesize,        // int Size,
                        0,               // Verbose
                        modVerCallback);

    if (g_targetOS == PLATFORM_MACOS) {
        // The secret sauce: change the PTRAUTH version, since with version 0
        // macOS's XNU kills the binary.
        mh->cpusubtype = 0x81000002;
        printf("Adjusting CPU subtype to %x\n", mh->cpusubtype);
    }

    char *outName = malloc(1024);
    sprintf(outName, "/tmp/%s", basename(fileName));

    int out = open(outName, O_WRONLY | O_CREAT | O_TRUNC);
    if (out < 0) {
        perror(outName);
        exit(66);
    }

    fchmod(out, 0755);
    write(out, mmapped, filesize);
    close(out);
    char *cmd = malloc(2048);

    // Remove the signature, the quick and dirty way. Feel free to replace
    // with ldid/jtool/etc
    sprintf(cmd, "/usr/bin/codesign --remove-signature %s", outName);
    system(cmd);
    sprintf(cmd, "/usr/bin/codesign -s - %s", outName);
    system(cmd);
    fprintf(stdout, "Output to %s\n", outName);
    return 0;
}
    

We are hiring for multiple positions - more details here

Andy Bartlomain

I design and develop custom, professional Squarespace websites for businesses, restaurants, churches, and entrepreneurs. I code beyond Squarespace's limitations to create unique layouts that best fit my clients' content and needs. The custom Squarespace websites I develop are optimized for any browser window size and is coded to keep Squarespace's built-in ease-of-use intact. My custom Squarespace designs will not make ongoing updates and maintenance complicated!

https://www.connectionmadedesign.com
Previous
Previous

‘tis the Season

Next
Next

iOS 17: New Version, New Acronyms | Round 2