SuperAPI AI Directory

re-format-macho

Security & auditing

View repository

Updated 10-15 21:34

About this skill

Mach-O format parsing

When to use / when not to use

  • Use: target is a macOS/iOS Mach-O binary (executable/.dylib/.bundle/Framework), need to understand structure, load commands, entry point, code signing, dynamic library dependency chain
  • Use: check for dylib injection (abnormal LC_LOAD_DYLIB entries), signing/notarization status, universal binary multi-architecture slices
  • Don't use: PE (use [[re-format-pe]]), ELF (use [[re-format-elf]])
  • Don't use: only need function logic (go straight to decompilation skill); iOS jailbreak environment issues belong to the [[re-mobile]] domain

Tool preparation

See the macOS branch of [[re-analyze/platform-tips]]—attach/debugging is restricted by SIP and TCC; static parsing is unaffected.

otool (bundled with Apple)

  • macOS: Xcode Command Line Tools: xcode-select --install
  • Verify: otool --version
  • Linux alternative: llvm-otool(apt install llvm / brew install llvm / pacman -S llvm), functional coverage of otool's commonly used subcommands

llvm-objdump (cross-platform)

  • macOS: brew install llvm;Linux: apt install llvm / dnf install llvm / pacman -S llvm
  • Verify: llvm-objdump --version
  • Usage: llvm-objdump --macho --private-headers sample(on Linux can directly parse Mach-O, output consistent with macOS)

jtool2 (deep Mach-O structure, macOS)

  • macOS: clone from GitHub (intezer/jtool2) then make, or download the release binary
  • Verify: jtool2 -h sample outputs load commands

codesign / lipo (bundled with macOS)

  • Verify: codesign --version;lipo -info sample
  • Linux alternative: llvm-lipo -info sample(bundled with llvm)

dyld_shared_cache_util(macOS)

  • Bundled with the system (/usr/bin/dyld_shared_cache_util); purpose: extract a single library when parsing system dylibs in the dyld shared cache

Operation steps

  1. Initial survey (first confirm type and architecture):

    file sample                  # 可执行文件 / dylib / 通用二进制 / 字节序
    lipo -info sample            # 通用二进制列出各架构片;单架构提示 "non-fat file"
    xxd -l 16 sample             # 魔数: cffaedfe=64位小端, feedfacf=64位大端, cebafaed=32位小端, cafebabe=fat
    

    Magic numbers (bytes seen in the file):cffa edfe(MH_MAGIC_64)、cefa edfe(MH_MAGIC 32-bit),cafe babe(FAT, followed by 4-byte architecture count). For universal binaries, first lipo -thin <arch> -output piece sample extract a single slice then parse.

  2. mach_header and load commands (core):

    otool -h sample                 # magic/cputype/filetype/ncmds/flag 一行全出
    otool -l sample | head -80      # 全部 load commands
    

    The 64-bit mach_header is a fixed 32 bytes (28 bytes for 32-bit): magic(4) + cputype(4) + cpusubtype(4) + filetype(4) + ncmds(4) + sizeofcmds(4) + flags(4) + reserved(4, 64-bit only). load commands are Mach-O's "section table + import table + entry point + signature" combined—iterate in cmdsize order from start to end,ncmds counts,sizeofcmds is the total byte count, the two cross-check each other. filetype: 2=MH_EXECUTE 6=MH_DYLIB 8=MH_BUNDLE. For common LC values and field tables see [[layout]].

  3. Segments and sections (__TEXT/__DATA/__LINKEDIT):

    otool -l sample | grep -A8 'segname __TEXT'
    otool -l sample | grep -A8 'segname __DATA'
    llvm-objdump --macho --section-headers sample
    

    Records each segment's vmaddr/vmsize/fileoff;__TEXT,__text is code,__DATA,__data is writable data, GOT is in __DATA_CONST,__got(read-only constant segment; in older binaries __DATA,__got);__LINKEDIT isnormal segment with vmaddr/vmsize, usually mapped as a read-only region (visible in vmmap), storing link metadata such as symbol table/string table/relocations/signature—the specific way it exists is affected by dyld and the shared cache.

  4. Entry point (LC_MAIN / LC_UNIXTHREAD):

    otool -l sample | grep -A6 LC_MAIN
    

    entryoff is the offset of the entry relative to the file header; entry VA = __TEXT segment vmaddr + entryoff (on 64-bit, the __TEXT base address is usually 0x100000000). Older binaries use LC_UNIXTHREAD (the register state contains the entry point). An executable with neither LC_MAIN nor LC_UNIXTHREAD is malformed and should be investigated as a priority.

  5. dyld information (LC_DYLD_INFO_ONLY / export trie):

    otool -l sample | grep -A9 LC_DYLD_INFO_ONLY
    llvm-objdump --macho --exports-trie sample   # 导出符号(trie 解码)
    

    five groups of offset+size, 48 bytes total, all in LC_DYLD_INFO_ONLY: rebase (base address fixup points), bind (external symbol bindings, non-lazy), weak_bind (weak symbol bindings; weak symbol coalescing semantics are independent and must not be merged into bind), lazy_bind (lazy bindings), export (export trie); newer binaries use LC_DYLD_CHAINED_FIXUPS (chained fixups, default on iOS 13+). A dylib's exported symbols are in the trie,nm -gU same effect.

  6. Dynamic library dependency chain (injection detection):

    otool -L sample                 # LC_LOAD_DYLIB 列表(依赖库 + install name)
    otool -l sample | grep -A4 LC_LOAD_DYLIB
    

    Each LC_LOAD_DYLIB: name (absolute path or @rpath/@executable_path relative) + timestamp/current_version.Malicious dylib persistence = adding a new LC_LOAD_DYLIB entry—inconsistency with the list at signing time is an important warning (see Pitfalls for details). Under two-level namespace, symbol lookup is by the (library, symbol) pair, and import resolution is strict.

  7. Symbol table (nlist_64):

    llvm-nm -m sample                # 含段归属与 N_EXT 标记;undefined = 导入
    llvm-nm -gU sample               # 仅外部定义符号(导出)
    otool -l sample | grep -A3 LC_SYMTAB   # symoff/nsyms/stroff/strsize 定位表
    

    Each nlist_64 entry is 16 bytes: n_strx (string table offset) + n_type (N_EXT/N_SECT etc.) + n_sect + n_desc + n_value. After stripping, .symtab disappears, leaving only LC_DYLD_INFO's export trie and dynamic binding information—the recovery approach is the same as for ELF ([[re-imports]] signature matching + string cross-references).

  8. Code signature check:

    codesign -dv sample 2>&1          # 签名类型(adhoc / Apple Development / not signed)
    codesign --verify --deep sample   # 完整性验证
    jtool2 --sig sample | head -20    # LC_CODE_SIGNATURE 细节(CodeDirectory/哈希)
    
    • adhoc: self-signed, can be modified and re-signed (codesign -f -s - sample)
    • Apple signature: modifying any byte invalidates it; with Library Validation enabled, injecting a dylib will fail
    • No LC_CODE_SIGNATURE = unsigned (cannot run directly on iOS)
  9. Manual parsing and verification: when load command output is abnormal / header fields are forged, use xxd + Python struct to parse the mach_header and each LC directly by offset (see [[examples]] for a minimal runnable example and byte samples); do not discard parsing failures as "corrupted files".

Cross-domain connections

  • [[re-binary-core]]: workflow step 3, Mach-O target format parsing
  • [[re-mobile]]: prerequisite for iOS App/jailbreak environment analysis (arm64 Mach-O main binary and dylibs)
  • [[re-ios]] / [[re-macos]]: ecosystem linkage (App layer, signing and notarization, TCC/SIP restrictions)
  • [[re-cracking]]: macOS cracking-type tasks (see step 8 of this skill for the re-signing process after patching)
  • [[re-imports]]: analysis of the hijacking surface of dylib dependencies and exported symbols
  • Dynamic debugging with [[re-lldb]] (see [[re-analyze/platform-tips]] for SIP/TCC restrictions)

Common pitfalls and traps

  • Signature verification blocks debugging/injection: a modified binary fails codesign verification and cannot run—first codesign -f -s - re-sign (only for self-signed/adhoc scenarios); with Library Validation enabled, DYLD_INSERT_LIBRARIES fails
  • iOS requires a jailbroken environment: debugging on a real device requires a developer certificate or jailbreak, and simulator processes are sandbox-restricted—static parsing can be done first, leaving the dynamic part to [[re-mobile]]
  • LC_LOAD_DYLIB injection detection: malicious dylibs achieve persistence by adding an LC_LOAD_DYLIB—otool -L A mismatch between the list and the list at signing time is an important alert
  • A universal binary contains multiple architecture slices: first lipo -info confirm the architecture, then lipo -thin extract it; do not parse the whole file; the offset of each slice in the fat header is an absolute file offset, so slice out a single slice by (offset, size)
  • The GOT location migrates with versions: the GOT of new artifacts is in __DATA_CONST,__got(read-only, relocated once after loading); old artifacts have it in __DATA,__got(writable)—to find the GOT, first otool -l look at the section name, do not assume
  • There is no standalone dylib file in the dyld shared cache: system libraries (libSystem.dylib, etc.) are actually inside the /System/Library/dyld/ cache; the file system only has stubs—to analyze system libraries, use dyld_shared_cache_util -extract to extract them
  • load command traversal misalignment: symptom—otool -l output becomes garbled/errors midway; cause—ncmds and sizeofcmds are forged or the cmdsize of some LC is abnormal; countermeasure—all LCs are at least 8 bytes (load_command the header itself cmd+cmdsize), under 64-bit cmdsize must be a multiple of 8, under 32-bit a multiple of 4, then validate each specific cmd by its structure's minimum size and trailing section/strings, with the total not exceeding sizeofcmds
  • __LINKEDIT is a mapped segment, but its contents may be processed by dyld: it has its own vmaddr/vmsize, usually existing as a read-only mapping (vmmap visible); however, the link metadata of shared cache libraries resides in the shared cache, so finding the symbol table directly from process memory by file offset often does not match—to locate the symbol table, prioritize using nm/dyldinfo/MachOView, etc., to parse according to LC_SYMTAB, rather than copying file offsets