[iOS翻译]Hack原生ARM64二进制文件以在iOS模拟器上运行

1,055 阅读10分钟

本文由 简悦SimpRead 转码,原文地址 bogo.wtf

关于如何在iOS模拟器中直接启动原生ARM64二进制文件的入门读物,使用otool、Mach-O、a......

2021-02-10

Bogo Giertler17分钟的阅读(和来源)。

注意:(bogo) 这篇文章的重点是 静态 库。我写了一篇单独的文章,解释了如何使用这种技术让ARM64 动态 库在iOS模拟器上运行。

image.png

上面的截图看起来非常正常--直到你意识到,在这台M1 MacBook上运行的示例应用程序实际上是2017年的一个传统Spotify SDK演示。其专有的二进制框架从未被重建以支持M1 Macs,并且不能在苹果最新的计算机上运行,除非通过Rosetta 2启动Xcode。

如果你有一台M1 Mac,你可能已经遇到了这个问题。在你最喜欢的项目上点击 "运行 "几秒钟后(并想_哇,这些M1 Macs确实很快!_),你可能会看到这个。

ld: 在 ../.../SpotifyiOS.framework/SpotifyiOS(MPMessagePackReader.o)中,为iOS模拟器构建,但在为iOS构建的对象文件中链接,文件'.../.../SpotifyiOS.framework/SpotifyiOS' 为arm64架构
clang: error: linker command failed with exit code 1 (use -v to see invocation)


通俗地说,你在项目中使用的专有二进制框架没有被更新以支持iOS模拟器在M1 Mac上运行。在这种情况下,苹果的建议是与供应商联系,要求他们发布框架的更新版本--既要把它迁移到XCFramework格式,又要重建它以支持M1模拟器。

有很多原因导致你可能无法很快得到更新的框架--甚至根本无法得到。常见的情况是,第三方供应商反应迟钝,或者你因为兼容性的原因而钉在框架的前一个主要版本上。由于你可能没有原始库的源代码,你也不能自己重建它。这意味着没有模拟器构建,没有本地单元和UI测试。你似乎遇到了一个死胡同,在M1 Mac上的开发暂时会非常困难。或者你有吗?

上周,我在Spotify的iOS SDK遇到了这个问题。由于二进制版本已经一年多没有更新了,我不得不找到一种方法来破解原生的ARM64二进制文件以在模拟器上运行。在这个过程中,我学到了很多关于框架、二进制文件和加载器的知识。你可以在GitHub上找到arm64-to-sim的完整来源。下面是对ARM64转制的详细解释。

💡 一个想法生根发芽了

让我们再看一下错误信息。我们收到的错误实际上不是一个编译器错误--是一个链接器错误。ld抱怨说,我们正试图将一个为 native ARM64编译的二进制文件链接到一个为 iOS Simulator ARM64构建的二进制文件。

历史上,苹果产品线中的ARM/x86分叉意味着我们可以安全地假设,为 "i386 "和 "x86_64 "构建的代码是为模拟器设计的,而为 "armv7 "和 "arm64 "构建的代码是为本地设备设计的。这在fat (universal) binaries中得到了反映,它是一个广泛使用的黑客,用于分发苹果平台的框架,可以用于设备和模拟器。

随着M1 Macs的发布,这一假设不再成立--ARM64切片现在可以用于任何一种。在一个框架支持macOS、iOS、watchOS和tvOS的幌子下,2019年苹果发布了一种新的捆绑框架格式,XCFramework

这应该给了我们一个想法:既然如ld错误所示,我们的库中已经有了一个原生ARM64分片,也许我们可以把它重新打包成一个支持iOS模拟器的XCFramework。没有技术上的原因,它不应该工作--编译后的二进制文件与其他框架和二进制文件的符号链接。由于iOS设备和M1 Macs使用相同的ARM64指令集,如果本地库和模拟器库的符号足够相似,该库应该可以正常工作。我们只需要运用大量的技巧。

🫀 (XC)框架的剖析

XCFramework是一种相当直接的格式,旨在成为原始Cocoa Frameworks的直接替代品。基本上,每个XCFramework都是一个目录,包含一个属性列表,告诉链接器在哪里找到每个框架的架构和格式的特定副本。

一个XCFramework的例子如下。

Example.xcframework/
|-- Info.plist
|-- ios-arm64/
|   +-- Example.framework/
+-- ios-arm64_x86_64-simulator/
    +-- Example.framework/

各个框架与平台的实际映射是在Info.plist文件中完成的。注意SupportedArchitecturesSupportedPlatformSupportedPlatformVariant属性。

<?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">
<dict>
	<key>AvailableLibraries</key>
	<array>
		<dict>
			<key>LibraryIdentifier</key>
			<string>ios-arm64</string>
			<key>LibraryPath</key>
			<string>Example.framework</string>
			<key>SupportedArchitectures</key>
			<array>
				<string>arm64</string>
			</array>
			<key>SupportedPlatform</key>
			<string>ios</string>
		</dict>
		<dict>
			<key>LibraryIdentifier</key>
			<string>ios-arm64_x86_64-simulator</string>
			<key>LibraryPath</key>
			<string>Example.framework</string>
			<key>SupportedArchitectures</key>
			<array>
				<string>arm64</string>
				<string>x86_64</string>
			</array>
			<key>SupportedPlatform</key>
			<string>ios</string>
			<key>SupportedPlatformVariant</key>
			<string>simulator</string>
		</dict>
	</array>
	<key>CFBundlePackageType</key>
	<string>XFWK</string>
	<key>XCFrameworkFormatVersion</key>
	<string>1.0</string>
</dict>
</plist>

在创建了一个相关的文件夹结构,并将Info.plist和我们的传统.framework放在一起之后,我们现在应该有一个真正的.xcframework了。让我们把原来的.framework放到Xcode中,并尝试构建。当然,如果能成功,那就太容易了--相反,我们得到了以下结果。

image.png

ld: in /Users/bogo/Library/Developer/Xcode/DerivedData/NowPlayingView-aeukgqexpeqlsrdzslkpeehveixs/Build/Products/Debug-iphonesimulator/SpotifyiOS.framework/SpotifyiOS(MPMessagePackReader.o), building for iOS Simulator, but linking in object file built for iOS, file '/Users/bogo/Library/Developer/Xcode/DerivedData/NowPlayingView-aeukgqexpeqlsrdzslkpeehveixs/Build/Products/Debug-iphonesimulator/SpotifyiOS.framework/SpotifyiOS' for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

由于Cocoa框架来自 "DerivedData",我们可以确定我们的XCFramework组装正确。不过,我们还是回到了原点--尽管我们的包装很天真,链接器还是能看出我们带来了一个本地库。这就是我们的新目标:找到一种方法让ld相信这个库实际上是一个模拟器库。

🕵️进入二进制文件

让我们看看我们的框架内部,看看有哪些文件可以告诉它平台的情况。

Example.framework/
|-- Info.plist
|-- Example
|-- Headers/
|   |-- A.h
|   |-- B.h
|   +-- C.h
+-- Modules/
    +-- module.modulemap

粗略地浏览一下框架的可读内容并没有得到任何提示,所以链接器必须使用二进制文件本身的内容来推断模拟器的信息。既然,我们不知道该找什么,那就先挖掘一下外面其他XCFrameworks的二进制文件吧。

FirebaseAnalytics.xcframework是一个特别值得研究的XCFramework--它包含本地和模拟器的二进制文件。第一个明显的想法是在二进制文件的可读字符串中搜索模拟器引用。

# in the FirebaseAnalytics.xcframework directory
$ strings ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics | grep -i sim

结果是一堆相当无趣的字符串,其中没有一个提到模拟器。我们可以做一个有根据的猜测,模拟器的信息就这样被编码在二进制文件的机器可读部分。为了提取它,我们可以使用otool--一个旨在探索由LLVM产生的可执行文件的工具。-fahl参数会打印出相关的fat、archive和Mach-O头文件,以及加载命令。

# in the FirebaseAnalytics.xcframework directory
$ otool -fahl ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics
(...)
Load command 2
      cmd LC_LINKER_OPTIMIZATION_HINT
  cmdsize 16
  dataoff 12464
 datasize 760
Load command 3
     cmd LC_SYMTAB
 cmdsize 24
  symoff 13224
   nsyms 201
  stroff 16440
 strsize 5064
(...)

哎呀,这是个很大的数据量啊! 这些偏移量、地址和大小对我们没有任何好处,而且在不同的平台上可能是不同的。让我们把我们的搜索限制在加载命令,保存结果,并进行比较。

# in the FirebaseAnalytics.xcframework directory
$ otool -fahl ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics | grep -E 'cmd |\.o' > simulator_cmds

$ otool -fahl ios-arm64_armv7/FirebaseAnalytics.framework/FirebaseAnalytics | grep -E 'cmd |\.o' > native_cmds

$ diff -u native_cmds simulator_cmds
-ios-arm64_armv7/FirebaseAnalytics.framework/FirebaseAnalytics(FirebaseAnalytics_vers.o):
+ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics(FirebaseAnalytics_vers.o):
       cmd LC_SEGMENT_64
-      cmd LC_VERSION_MIN_IPHONEOS
+      cmd LC_BUILD_VERSION
      cmd LC_SYMTAB
(...)

好了,我们找到了一个匹配点! 似乎模拟器的二进制文件包含一个 "LC_BUILD_VERSION "加载命令,而本地二进制文件在同一个地方包含一个 "LC_VERSION_MIN_IPHONEOS "加载命令。用otool对我们不支持的、只有本地的.framework进行测试,证实了这个理论。在谷歌上搜索了一下,发现这个特定的差异被LLDB用来区分模拟器和本地二进制文件。那么我们就在正确的轨道上了--看起来用 "LC_VERSION_MIN_IPHONEOS "代替 "LC_BUILD_VERSION "可能就足以骗过 "ld"。

📚 认识图书管理员

到目前为止,我们一直在玩一个胖的二进制文件,包含多个特定平台的切片。我们可以使用 file 命令查看二进制文件中可用的架构。

$ file Example.framework/Example
Example.framework/Example Mach-O universal binary with 4 architectures: [i386:current ar archive] [arm_v7] [x86_64] [arm64]
Example.framework/Example (for architecture i386):        current ar archive
Example.framework/Example (for architecture armv7):       current ar archive
Example.framework/Example (for architecture x86_64):      current ar archive
Example.framework/Example (for architecture arm64):       current ar archive

很明显,对于我们的目的,我们并不特别关心x86或ARMv7的切片。所以我们只抓取arm64一个。

$ lipo -thin arm64 Example.framework/Example -output Example.arm64

如果我们用十六进制编辑器(比如Hex Fiend)打开得到的Example.arm64文件,我们应该注意到该文件的magic number (file format identification pattern)在ASCII中拼写为!<arch>--这意味着,我们不是在处理一个单独的 二进制文件,而是一个UNIX的二进制文件档案--一个 资料库。我们可以很容易地解开它。

结果应该是一些看起来很熟悉的".o "文件。如果我们在十六进制编辑器中打开任何一个文件,我们应该看到CFFA EDFE作为最初的2个字节--一个小字节编码的FEED FACE + 1,这是ARM64 Mach-O二进制文件的神奇数字。(原来的 "FEED FACE "是ARM32的神奇数字)。

在这一点上,我们已经从XCFramework,到Cocoa Framework,到UNIX库,到我们最终可以编辑的单个Mach-O二进制对象--真正的,一个抽象层的matryoshka跨越了近40年的计算历史

✂️剖析Mach-O二进制

虽然尝试在十六进制编辑器中阅读和编辑对象文件是可能的,但它很快就会被证明是一个愚蠢的错误。看一下Mach-O加载器的公开可用的XNU源,可以看到LC_VERSION_MIN_IPHONELC_BUILD_VERSION的大小不同。LC_BUILD_VERSION结构有两个额外的UInt32大小的字段,这意味着它比LC_VERSION_MIN_IPHONE正好长8字节。

虽然LLVM生成的机器码段是position independent,但头文件和加载命令不是 - 毕竟它们是加载器的 导航指令。为了在二进制文件中放置一个较长的加载命令,我们需要重新创建二进制文件,并将所有的引用调整到与二进制文件开头的绝对距离。(通常被称为 offsets)

要做到这一点,我们首先需要了解什么是Mach-O二进制文件的构成。根据官方Mach-O ABI docsloader.h规范,Mach-O二进制文件可以分成4个基本部分。

  • 一个Mach-O头,它包含神奇的数字(如CAFE BABEFEED FACE),支持的CPU类型,以及加载命令的数量和大小。
  • 一个加载命令表,它是任意长的,通知链接器关于二进制文件和在哪里找到原始内容中感兴趣的部分。
  • 一个可选择的填充物,可以由开发者添加,以简化对二进制文件的后续编辑。 原始内容,包含可执行代码、字符串和实际执行所需的其他一切--所有这些都在加载命令表中描述的偏移处。

所有这些组件在 "MachO "框架中被描述为字节对齐的C结构。有了这些知识,我们可以开始实现一个简单的命令行工具,将任何Mach-O二进制文件从本地文件转化为模拟器文件。

import Foundation
import MachO

extension Data {
    func asStruct<T>(fromByteOffset offset: Int = 0) -> T {
        return withUnsafeBytes { $0.load(fromByteOffset: offset, as: T.self) }
    }
}

let path = CommandLine.arguments[1]
guard let handle = FileHandle(forReadingAtPath: path) else {
    fatalError()
}

let headerData = try! handle.read(upToCount: MemoryLayout<mach_header_64>.stride)!
let header: mach_header_64 = headerData.asStruct()
if header.magic != MH_MAGIC_64 || header.cputype != CPU_TYPE_ARM64 {
    fatalError()
}

如果我们给转换器提供了一个有效的Mach-O二进制文件,header结构现在将告知我们加载命令的数量和它们的总大小。要读取Mach-O头之后的单个加载命令,我们需要理解两件事:代表Mach-O命令的C结构不是明确的多态的--它们隐含地跟随load_command,并且单个加载命令的大小是不固定的。

换句话说,每个加载命令都以一个load_command结构开始。这个结构正好包含两个32位的整数--描述命令的 类型 和命令的 大小 。为了正确的读取命令,我们需要支持窥视我们的文件柄,并直接从原始数据对象中检查命令的大小和类型。

extension Data {
    var loadCommand: UInt32 {
        let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) }
        return lc.cmd
    }

    var commandSize: UInt32 {
        let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) }
        return lc.cmdsize
    }
}

extension FileHandle {
    func peek(upToCount count: Int) throws -> Data? {
        let originalOffset = offsetInFile
        let data = try read(upToCount: count)
        try seek(toOffset: originalOffset)
        return data
    }
}

有了这些函数,我们现在可以开始将单个命令提取到Data blobs中。

let loadCommandsData: [Data] = (0..<header.ncmds).map { _ in
    let loadCommandPeekData = try! handle.peek(upToCount: MemoryLayout<load_command>.stride)
    return try! handle.read(upToCount: Int(loadCommandPeekData!.commandSize))!
}

最难处理的单个加载命令是LC_SEGMENT_64。与Mach-O头相似,LC_SEGMENT_64实际上是由一个segment_command_64结构组成,后面是任意数量的section_64

var segment: segment_command_64 = data.asStruct()

let sections: [section_64] = (0..<Int(segment.nsects)).map { index in
    let sectionOffset = MemoryLayout<segment_command_64>.stride + index * MemoryLayout<section_64>.stride
    return data.asStruct(fromByteOffset: sectionOffset)
}

有了所有的命令在内存中,我们现在可以通过保存剩余的命令来完成对二进制文件的读取,以便以后处理。

let programData = try! handle.readToEnd()!
try! handle.close()

最后,我们需要将整个文件持久化到磁盘上。由于我们要处理大量的Data数组,我们也要简化它们的处理。

extension Array where Element == Data {
    var flattened: Data { reduce(into: Data()) { $0.append($1) } }
}

try! [
    Data(bytes: &header, count: MemoryLayout<mach_header_64>.stride),
    loadCommandsData.flattened
    programData,
].flattened.write(to: URL(fileURLWithPath: "\(path).reworked.o"))

结果文件应该与输入文件完全相同。我们可以用cmp来确认这一点。

$ cmp -s input.o input.o.reworked.o || echo "Files are different!

如果我们没有看到任何错误,说明重新组装工作符合预期。我们现在可以安全地编辑二进制的加载命令。

🚀Raison d'Être

在这一点上,我们以外科手术般的精确度分解了二进制文件,并准备好编辑其各个组成部分。最大的变化当然是摆脱LC_VERSION_MIN_IPHONEOS命令,用LC_BUILD_VERSION的实例来代替它。一旦这样做了,我们需要重建以下加载命令中的偏移量。

  • LC_SEGMENT_64 - 我们需要更新各个部分的offsetreloff属性,以及整个二进制文件的fileofffilesizevmsize属性。
  • LC_DATA_IN_CODELC_LINKER_OPTIMIZATION_HINT - 使用相同的C结构表示,都需要更新dataoff属性。
  • LC_SYMTAB - 我们需要改变stroffsymoff属性,分别用于字符串和符号表数据。

对加载命令进行所有更新的最直接的方法,是简单地使用Swift的map,并在辅助函数中处理更新。

let offset = UInt32(abs(MemoryLayout<build_version_command>.stride - MemoryLayout<version_min_command>.stride))
let editedCommandsData = loadCommandsData
    .map { (lc) -> Data in
        switch lc.loadCommand {
        case LC_SEGMENT_64:
            return updateSegment64(lc, offset)
        case LC_VERSION_MIN_IPHONEOS:
            return updateVersionMin(lc, offset)
        case LC_DATA_IN_CODE, LC_LINKER_OPTIMIZATION_HINT:
            return updateDataInCode(lc, offset)
        case LC_SYMTAB:
            return updateSymTab(lc, offset)
        case LC_BUILD_VERSION:
            fatalError()
        default:
            return lc
        }
    }
    .merge()

对于处理LC_VERSION_MIN_IPHONE_OS,我们需要在辅助函数中返回一个包含build_version_command结构新实例的Data blob。对于其他的加载命令,我们只需更新C结构并将其作为数据对象返回。所有 "load_command "变化的单独实现都可以在项目的GitHub仓库中找到。

最后,也是最重要的,我们需要在二进制文件写回磁盘之前更新Mach-O头中的sizeofcmds属性。

header.sizeofcmds = UInt32(editedCommandsData.count)

在这一点上,运行我们的transmogrifier应该产生一个有效的ARM64模拟器文件。当然,更新一个二进制文件只能让我们走到这一步--我们仍然需要执行另外几个任务。

  • 在每个对象文件上使用transmogrifier。
  • 将这些对象归档到一个库中。
  • 将库与原来的x86_64切片合并,形成一个适合模拟器的胖二进制文件。
  • 在XCFramework中的Cocoa框架内替换原始库文件。

我们可以很容易地解决前两个问题。

$ for file in *.o; do arm64-to-sim $file; done;
$ ar crv ../Example.arm64-reworked *.reworked.o

作为组装库的一部分,ar试图从提供的二进制文件中构建一个索引。这个过程需要进行广泛的检查,以确认每个对象是有效的可执行文件,值得庆幸的是,会产生详细的错误。如果我们在重建偏移量时有任何错误或遗漏,ar会告诉我们哪个部分有问题,以及它与什么重合。从这里开始,我们只需要在我们的代码中不断地进行编辑。一旦ar满意了,我们就可以将我们转换后的ARM64片断与Intel的片断合并。

$ lipo -create -output Example Example.x86_64 Example.arm64-reworked

终于来了! 我们现在有了一个包含 ARM64 和 x86-64 模拟器片的胖二进制文件。在用我们黑掉的库文件替换了框架中的原始库文件后,是时候进行一次⌘+R和一个关键时刻了......

image.png

轰隆隆! 一个原生的ARM64框架被黑掉了,可以作为模拟器框架在M1 Macs上运行! 🎉

🤓学习和死角

我总共花了15个小时才想出这个方案。一路走来,我明白了。

  • Cocoa框架、UNIX库和Mach-O二进制文件是如何构建的。
  • ARM64代码比我想象的更普遍,而且苹果公司在不同平台上的代码是非常一致的。
  • 二进制文件只是更高级的有限自动机

如果没有一些死胡同,这就不是一个令人兴奋的项目。我徒劳的尝试包括。

  • 使用开源安全研究工具,但无论是LIEF还是ruby-macho都不能可靠地编辑在Mach-O头中插入额外命令所需的偏移量--相反,它们要求在构建二进制文件时加入额外的填充。
  • 通过从LC_SEGMENT_64中删除80字节长的__cmdline部分,释放出 "缺失 "的8个字节--最初我不敢编辑加载命令的二进制偏移量,但这仍然导致ar抱怨偏移量。
  • 通过剥离Bitcode(xcrun bitcode_strip $input -m -o $output)在二进制文件中获得更多的 "空间"--产生的二进制文件不包含任何额外的填充。

👍 未来的改进

对该工具的一些潜在改进是留给读者的练习。这些改进可以是。

  • 通过对加载命令进行两次处理,使偏移量的处理更加动态,并传递数据/偏移量对(`DOP')来代替。
  • 在新的 "build_version_command "中保留原 "version_min_command "中的最小iOS和SDK值。
  • 扩展二进制黑客技术,以支持其他苹果平台(tvOS、watchOS等)的本机和模拟器。
  • 通过module.modulemap公开伞状头文件,将传统框架变成真正的Clang模块;(以避免Swift桥接头文件的问题)

🙌 参考文献和贡献

有几个人和项目值得特别感谢,因为他们使这项工作变得更容易。

  • Zac West - 感谢你为我指出了加载器命令的方向,特别是LC_BUILD_VERSION
  • Hex FiendMach-O Browser - 两种可靠的工具,分别用于探索和比较原始二进制文件,以及研究Mach-O二进制文件的加载命令。
  • LIEFmacho-ruby - 两个伟大的开源库,用于独立于平台的Mach-O二进制文件的读取(编写时有点¯\_(ツ)_/¯)。
  • marzipanify - Steven Troughton-Smith在2018年试图解决一个类似的问题,尽管是在另一个方向。
  • Macho-O内部介绍 - William Woodruff准备了一份关于Mach-O二进制文件如何组织的出色解释。

www.deepl.com 翻译