logo
Published on

Building a Dynamic Mach-O ARM64 Packer: Low-Level Binary Manipulation

Authors
  • avatar
    Name
    John Decorte
    Bluesky

Understanding Mach-O: The Blueprint of macOS Binaries

Welcome to the world of Mach-O files! Before we can dive into code injection or create something as cool as your own packer, we need to get our hands dirty and understand the intricate structure of these binaries. Think of this as your blueprint for understanding how macOS executables work under the hood.

By the end of this guide, you'll not only feel comfortable navigating a Mach-O file, but also confident in modifying one. Let’s jump in.


What's a Mach-O File, Really?

Imagine a Mach-O file as a well-organized toolbox. Inside, you’ve got everything macOS needs to:

  • Load the program into memory.
  • Resolve any external dependencies (like dynamic libraries).
  • Protect the program with security features.
  • Execute the program starting at the right place.

It’s a modular format, which means every piece has a job, and the system knows exactly where to find it. This precision makes Mach-O files powerful but also a little tricky to work with—mess up one part, and the whole thing might crash. That’s why understanding its structure is so crucial.

Peeking Inside a Mach-O File

To understand a Mach-O file, let’s break it down into its major components. Each part serves a specific purpose, like gears in a machine.

1. The Header: The File's Introduction

The header is the very first thing you’ll find in a Mach-O file. It’s like the front page of a book—it tells the system what kind of file it’s dealing with.

Here are some important details stored in the header:

  • Magic Number: Identifies the file as a Mach-O binary (e.g., MH_MAGIC_64 for 64-bit files).
  • CPU Type and Subtype: Specifies the architecture (like ARM64 for Apple Silicon or x86_64).
  • File Type: Indicates whether it’s an executable, library, or object file.
  • Load Command Info: Includes how many load commands follow (ncmds) and their total size (sizeofcmds).

The header is small but mighty. It’s the first thing the system reads, so if it’s corrupted, the program won’t even start.

How to Inspect It: Want to see the header of a Mach-O file? Use otool like this:

otool -h /path/to/mach-o/binary

2. Load Commands: The Brain of the Binary

Right after the header, you’ll find the load commands. These are the real instructions for the operating system. They tell it how to:

  • Map the binary into memory.
  • Handle external libraries.
  • Set up the entry point for execution.

Some of the most common load commands include:

  • LC_SEGMENT_64: Describes segments in the binary, such as code or data.
  • LC_LOAD_DYLIB: Points to dynamic libraries the binary depends on.
  • LC_MAIN: Tells the system where to start execution.
  • LC_CODE_SIGNATURE: Holds the code signature to verify the binary’s integrity.

Why Load Commands Matter: When you’re injecting code or adding functionality, these commands will likely be the first thing you modify. For instance, adding a new library involves inserting an LC_LOAD_DYLIB command. Want to redirect execution? You’ll tweak the LC_MAIN command.

Toolbox Tip: Run this command to list all load commands in a Mach-O file:

otool -l /path/to/mach-o/binary

3. Segments and Sections: The Binary's Organization

Segments are like the big rooms in a house, and sections are the furniture inside them. Segments divide the binary into logical regions:

SegmentPurposeKey Sections
__TEXTWhere the program’s code lives. This segment is typically read-only and executable.__text (actual machine instructions), __stubs (dynamic linking data)
__DATAHolds writable data like global variables.__data (initialized global variables), __bss (uninitialized variables)
__LINKEDITStores metadata for linking, like symbol tables.

Why They Matter: If you’re injecting code, you’ll likely add it to the __TEXT segment or create a new segment altogether. Segments also dictate permissions (read, write, execute), so you’ll need to ensure your modifications don’t break these rules.

4. Entry Point: Where the Magic Starts

The entry point is where the system starts executing your program. It’s defined by the LC_MAIN load command, which provides the offset to the starting function. This is the heart of the binary, and modifying it is common in code injection.

Here’s what you’ll do when injecting:

  1. Redirect the entry point to point to your custom loader.
  2. Have your loader do its thing (decrypting, decompressing, etc.).
  3. Jump back to the original entry point to resume normal execution.

The Mach-O Packer: Advanced Mach-O Tricks

Now that you have a solid understanding of the Mach-O file structure, let’s take it up a notch and explore advanced techniques. These methods transform basic binary modifications into powerful, functional injections.

In this section, we focus on the art of dynamic injection—adding a dynamic library to an existing Mach-O binary. This is the core of many packing and customization projects and an essential skill for modifying macOS executables. Dynamic library injection allows you to extend the functionality of a program without rewriting its original code.

What is Dynamic Library Injection?

Dynamic library injection involves modifying a Mach-O binary to include a new LC_LOAD_DYLIB load command. This command instructs macOS to load a specified dynamic library at runtime. Once loaded, the library’s functions become accessible to the binary, allowing you to introduce new behavior or augment existing functionality.

In essence, you’re embedding a new dependency into the binary. The operating system will treat this library as if it were always part of the program.

Injecting a Dynamic Library: The Process

Dynamic injection involves a few key steps that require precise file manipulation:

Step 1: Extending the Load Commands

The first step in dynamic injection is adding an LC_LOAD_DYLIB load command. This command contains the library path and metadata, such as the library's compatibility and current version.

A typical LC_LOAD_DYLIB command structure looks like this:

struct dylib_command {
    uint32_t cmd;       // LC_LOAD_DYLIB
    uint32_t cmdsize;   // Size of this command (struct + path + padding)
    struct dylib {
        uint32_t name;  // Offset to the library path (relative to struct start)
        uint32_t timestamp;
        uint32_t current_version;
        uint32_t compatibility_version;
    } dylib;
};

To add this command, you must locate the end of the existing load commands, append the LC_LOAD_DYLIB structure, and write the library path immediately after. Ensure the cmdsize includes the size of the structure and the library path, padded to alignment.

Step 2: Adjusting the Mach-O Header

The Mach-O header specifies the total number and size of load commands. After adding the new load command, you must update these fields to reflect the changes:

  1. Increment the ncmds field to include the new command.
  2. Add the size of the new load command to sizeofcmds.

Example:

struct mach_header_64 header;
fread(&header, sizeof(header), 1, binary_file);

// Assuming new_command_size includes the command struct and the padded path
header.ncmds += 1;
header.sizeofcmds += new_command_size;

fseek(binary_file, 0, SEEK_SET);
fwrite(&header, sizeof(header), 1, binary_file);

Step 3: Writing the Library Path

The LC_LOAD_DYLIB command includes an offset to the library path, which must be appended to the binary after the load command section. The path must be null-terminated and padded to maintain 8-byte alignment, as required by Mach-O standards.

For instance, if you’re injecting a library located at /usr/local/lib/my_library.dylib, ensure the path fits within the allocated space or extend the binary accordingly.

char library_path[] = "/usr/local/lib/my_library.dylib";
size_t path_size = strlen(library_path) + 1; // Include null terminator

// Logic to calculate padding and write
fwrite(library_path, path_size, 1, binary_file);
// Append padding bytes here

Step 4: Testing the Injection

Dynamic library injection isn’t complete until you test your changes. Load the modified binary in a debugger or simply run it to ensure that:

  1. The library loads without errors.
  2. The program executes as expected.
  3. The new functionality introduced by the library is active.

Using otool, you can confirm that your library was successfully added:

otool -L modified_binary

You should see your injected library listed alongside the binary’s original dependencies.

Challenges and Considerations

Manipulating Mach-O files is difficult due to their strict requirements. Be mindful of these challenges:

  • Alignment and Padding: Mach-O binaries are highly sensitive to alignment. Ensure that the library path and load command are properly padded to align with memory boundaries (typically 8 bytes). Failure to do so will corrupt the binary.
  • File Size Limits: If there isn’t enough space for your new load command or library path, you must resize and realign the __LINKEDIT segment. This is a complex operation.
  • Code Signing: Modifying a Mach-O binary invalidates its code signature. To bypass this for testing purposes, you can remove the LC_CODE_SIGNATURE command. However, this disables macOS security checks and should only be used in controlled, non-production environments.

Putting It All Together: A Dynamic Injection Workflow

Here’s a summary workflow for injecting a dynamic library into a Mach-O binary:

  1. Locate the Load Commands: Use otool -l to inspect the existing load commands and determine the offset where the new command can be safely appended.
  2. Append the Load Command: Write the LC_LOAD_DYLIB structure to the binary, ensuring proper alignment.
  3. Add the Library Path: Append the null-terminated and padded library path to the binary immediately after the command.
  4. Update the Header: Increment the ncmds and adjust sizeofcmds in the Mach-O header at the start of the file.
  5. Test the Modified Binary: Run the binary and confirm that the library loads and functions as expected using otool -L.
  6. Debug Any Issues: Use lldb or other debuggers to trace runtime behavior if the modification introduces errors.

Disclaimer: The information you gather from this guide, all the techniques, proofs-of-concept code, or whatever else you may possibly find here, are strictly for educational purposes. I do not condone the usage of anything you might gather from this blog for malicious purposes. I’ve made this blog to consolidate my learning by teaching it to the world.