├── Sources ├── Tests │ ├── TestResources │ │ ├── return2.c.fixture │ │ ├── main.c.fixture │ │ └── return2.h.fixture │ └── Arm64ToSimTestCase.swift ├── arm64-to-sim │ └── main.swift └── Arm64ToSimLib │ └── Transmogrifier.swift ├── .gitignore ├── README.md ├── Package.swift └── LICENSE /Sources/Tests/TestResources/return2.c.fixture: -------------------------------------------------------------------------------- 1 | int return2() { 2 | return 2; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | *.xcworkspacedata 7 | -------------------------------------------------------------------------------- /Sources/Tests/TestResources/main.c.fixture: -------------------------------------------------------------------------------- 1 | #include "return2.h" 2 | 3 | int main() { 4 | return return2(); 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Tests/TestResources/return2.h.fixture: -------------------------------------------------------------------------------- 1 | #ifndef Header_h 2 | #define Header_h 3 | 4 | int return2(); 5 | 6 | #endif 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # arm64-to-sim 2 | 3 | A simple command-line tool for hacking native ARM64 binaries to run on the Apple Silicon iOS Simulator. 4 | 5 | ## Building 6 | 7 | In order to build a universal `arm64-to-sim` executable, run: 8 | 9 | ``` 10 | swift build -c release --arch arm64 --arch x86_64 11 | ``` 12 | 13 | This will output the executable into the `.build/apple/Products/Release` 14 | directory. 15 | -------------------------------------------------------------------------------- /Sources/arm64-to-sim/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Arm64ToSimLib 3 | 4 | guard CommandLine.arguments.count > 1 else { 5 | fatalError("Please add a path to command!") 6 | } 7 | 8 | let binaryPath = CommandLine.arguments[1] 9 | let minos = (CommandLine.arguments.count > 2 ? UInt32(CommandLine.arguments[2]) : nil) ?? 12 10 | let sdk = (CommandLine.arguments.count > 3 ? UInt32(CommandLine.arguments[3]) : nil) ?? 13 11 | let isDynamic = (CommandLine.arguments.count > 4 ? Bool(CommandLine.arguments[4]) : nil) ?? false 12 | if isDynamic { 13 | print("[arm64-to-sim] notice: running in dynamic framework mode") 14 | } 15 | 16 | Transmogrifier.processBinary(atPath: binaryPath, minos: minos, sdk: sdk, isDynamic: isDynamic) 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "arm64-to-sim", 6 | platforms: [ 7 | .macOS(.v11) 8 | ], 9 | products: [ 10 | .executable(name: "arm64-to-sim", targets: ["arm64-to-sim"]) 11 | ], 12 | dependencies: [ 13 | ], 14 | targets: [ 15 | .target( 16 | name: "arm64-to-sim", 17 | dependencies: [ "Arm64ToSimLib" ]), 18 | .target( 19 | name: "Arm64ToSimLib", 20 | dependencies: []), 21 | .testTarget( 22 | name: "Tests", 23 | dependencies: ["Arm64ToSimLib"], 24 | resources: [ 25 | .copy("TestResources"), 26 | ]) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bogo Giertler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/Tests/Arm64ToSimTestCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | import Arm64ToSimLib 4 | 5 | class Arm64ToSimTestCase: XCTestCase { 6 | 7 | 8 | var tempDir: URL! 9 | override func setUp() { 10 | self.tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID.init().uuidString) 11 | try! FileManager.default.createDirectory(at: self.tempDir, withIntermediateDirectories: false, attributes: nil) 12 | copyFixtures() 13 | } 14 | 15 | override func tearDown() { 16 | try! FileManager.default.removeItem(at: self.tempDir) 17 | } 18 | 19 | private func copyFixtures() { 20 | let testResourcesPath = Bundle.module.resourcePath!.appending("/TestResources") 21 | if let files = try? FileManager.default.contentsOfDirectory(atPath: testResourcesPath){ 22 | for file in files { 23 | var isDir : ObjCBool = false 24 | let fileURL = URL(fileURLWithPath: testResourcesPath).appendingPathComponent(file) 25 | if FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDir) { 26 | if !isDir.boolValue { 27 | try! FileManager.default.copyItem(at: fileURL, to: tempDir.appendingPathComponent(fileURL.lastPathComponent.replacingOccurrences(of: ".fixture", with: ""))) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | @discardableResult func runCommand(args: [String]) -> (String, Int32) { 35 | let task = Process() 36 | task.executableURL = URL(fileURLWithPath: args[0]) 37 | task.arguments = Array(args.dropFirst()) 38 | task.currentDirectoryURL = tempDir 39 | let pipe = Pipe() 40 | task.standardOutput = pipe 41 | task.standardError = pipe 42 | task.launch() 43 | task.waitUntilExit() 44 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 45 | let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) 46 | return (output!, task.terminationStatus) 47 | } 48 | 49 | private func testConvert(deviceTarget: String, simulatorTarget:String, file: StaticString = #file, line: UInt = #line) { 50 | let (sysroot, _) = runCommand(args: ["/usr/bin/xcrun", "--show-sdk-path", "--sdk", "iphonesimulator"]) 51 | runCommand(args: ["/usr/bin/clang", "-isysroot", sysroot, "-target", simulatorTarget, "-c", "main.c", "-o", "main.arm64.ios.simulator.o"]) 52 | runCommand(args: ["/usr/bin/clang", "-isysroot", sysroot, "-target", deviceTarget, "-c", "return2.c", "-o", "return2.ios.device.o"]) 53 | let (loadCommandsOutput, _) = runCommand(args: ["/usr/bin/otool", "-l", "return2.ios.device.o" ]) 54 | print("LOAD_COMMANDS:") 55 | for lc in loadCommandsOutput.split(separator: "\n").filter({$0.contains("cmd")}) { 56 | print(lc) 57 | } 58 | let (_, link_status_failing) = runCommand(args: ["/usr/bin/clang", "-isysroot", sysroot, "-target", deviceTarget, "main.arm64.ios.simulator.o", "return2.ios.device.o"]) 59 | XCTAssert(link_status_failing != 0) 60 | Transmogrifier.processBinary(atPath: tempDir.appendingPathComponent("return2.ios.device.o").path, minos: 13, sdk: 13, isDynamic: false) 61 | let (_, link_status_success) = runCommand(args: ["/usr/bin/clang", "-isysroot", sysroot, "-target", "arm64-apple-ios-simulator", "main.arm64.ios.simulator.o", "return2.ios.device.o"]) 62 | XCTAssert(link_status_success == 0) 63 | } 64 | 65 | func testConvertPreiOS12FileFormatToSim() { 66 | testConvert(deviceTarget: "arm64-apple-ios11", simulatorTarget: "arm64-apple-ios12-simulator") 67 | } 68 | 69 | func testConvertNewObjectFileFormatToSim() { 70 | testConvert(deviceTarget: "arm64-apple-ios12", simulatorTarget: "arm64-apple-ios12-simulator") 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Arm64ToSimLib/Transmogrifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MachO 3 | 4 | // support checking for Mach-O `cmd` and `cmdsize` properties 5 | extension Data { 6 | var loadCommand: UInt32 { 7 | let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) } 8 | return lc.cmd 9 | } 10 | 11 | var commandSize: Int { 12 | let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) } 13 | return Int(lc.cmdsize) 14 | } 15 | 16 | func asStruct(fromByteOffset offset: Int = 0) -> T { 17 | return withUnsafeBytes { $0.load(fromByteOffset: offset, as: T.self) } 18 | } 19 | } 20 | 21 | extension Array where Element == Data { 22 | func merge() -> Data { 23 | return reduce(into: Data()) { $0.append($1) } 24 | } 25 | } 26 | 27 | // support peeking at Data contents 28 | extension FileHandle { 29 | func peek(upToCount count: Int) throws -> Data? { 30 | // persist the current offset, since `upToCount` doesn't guarantee all bytes will be read 31 | let originalOffset = offsetInFile 32 | let data = try read(upToCount: count) 33 | try seek(toOffset: originalOffset) 34 | return data 35 | } 36 | } 37 | 38 | public enum Transmogrifier { 39 | private static func readBinary(atPath path: String, isDynamic: Bool = false) -> (Data, [Data], Data) { 40 | guard let handle = FileHandle(forReadingAtPath: path) else { 41 | fatalError("Cannot open a handle for the file at \(path). Aborting.") 42 | } 43 | 44 | // chop up the file into a relevant number of segments 45 | let headerData = try! handle.read(upToCount: MemoryLayout.stride)! 46 | 47 | let header: mach_header_64 = headerData.asStruct() 48 | if header.magic != MH_MAGIC_64 || header.cputype != CPU_TYPE_ARM64 { 49 | fatalError("The file is not a correct arm64 binary. Try thinning (via lipo) or unarchiving (via ar) first.") 50 | } 51 | 52 | let loadCommandsData: [Data] = (0...stride) 54 | return try! handle.read(upToCount: Int(loadCommandPeekData!.commandSize))! 55 | } 56 | 57 | if isDynamic { 58 | let bytesToDiscard = abs(MemoryLayout.stride - MemoryLayout.stride) 59 | _ = handle.readData(ofLength: bytesToDiscard) 60 | } 61 | 62 | let programData = try! handle.readToEnd()! 63 | 64 | try! handle.close() 65 | 66 | return (headerData, loadCommandsData, programData) 67 | } 68 | 69 | private static func updateSegment64(_ data: Data, _ offset: UInt32) -> Data { 70 | // decode both the segment_command_64 and the subsequent section_64s 71 | var segment: segment_command_64 = data.asStruct() 72 | 73 | let sections: [section_64] = (0...stride + index * MemoryLayout.stride 75 | return data.asStruct(fromByteOffset: offset) 76 | } 77 | 78 | // shift segment information by the offset 79 | segment.fileoff += UInt64(offset) 80 | segment.filesize += UInt64(offset) 81 | segment.vmsize += UInt64(offset) 82 | 83 | let offsetSections = sections.map { section -> section_64 in 84 | let sectionType = section.flags & UInt32(SECTION_TYPE) 85 | switch Int32(sectionType) { 86 | case S_ZEROFILL, S_GB_ZEROFILL, S_THREAD_LOCAL_ZEROFILL: 87 | return section 88 | case _: break 89 | } 90 | 91 | var section = section 92 | section.offset += UInt32(offset) 93 | section.reloff += section.reloff > 0 ? UInt32(offset) : 0 94 | return section 95 | } 96 | 97 | var datas = [Data]() 98 | datas.append(Data(bytes: &segment, count: MemoryLayout.stride)) 99 | datas.append(contentsOf: offsetSections.map { section in 100 | var section = section 101 | return Data(bytes: §ion, count: MemoryLayout.stride) 102 | }) 103 | 104 | return datas.merge() 105 | } 106 | 107 | private static func replaceVersionMin(_ data: Data, _ offset: UInt32, minos: UInt32, sdk: UInt32) -> Data { 108 | var command = build_version_command(cmd: UInt32(LC_BUILD_VERSION), 109 | cmdsize: UInt32(MemoryLayout.stride), 110 | platform: UInt32(PLATFORM_IOSSIMULATOR), 111 | minos: minos << 16 | 0 << 8 | 0, 112 | sdk: sdk << 16 | 0 << 8 | 0, 113 | ntools: 0) 114 | 115 | return Data(bytes: &command, count: MemoryLayout.stride) 116 | } 117 | 118 | private static func updateBuildVersion(_ data: Data, _ offset: UInt32, minos: UInt32, sdk: UInt32) -> Data { 119 | 120 | // a build version command consists of 2 parts: the build version header, and an optional list of 'build tool' headers. 121 | // see https://opensource.apple.com/source/dyld/dyld-519.2.1/dyld3/MachOParser.cpp for the struct definition. 122 | 123 | // In order to ensure we don't change the size of the command, 124 | // let's just mutate use the memory we already allocated when we read the command in from the object file. 125 | var data = data 126 | 127 | // Make a struct value we can mutate 128 | var new_command : build_version_command = data.asStruct() 129 | 130 | new_command.cmd = UInt32(LC_BUILD_VERSION) 131 | new_command.platform = UInt32(PLATFORM_IOSSIMULATOR) 132 | new_command.minos = minos << 16 | 0 << 8 | 0 133 | new_command.sdk = sdk << 16 | 0 << 8 | 0 134 | 135 | // commit our changes to the original command 136 | let new_data = Data(bytes: &new_command, count: MemoryLayout.stride) 137 | let replace_range : Range = 0...stride 138 | data.replaceSubrange(replace_range, with: new_data) 139 | 140 | return data 141 | } 142 | 143 | private static func updateDataInCode(_ data: Data, _ offset: UInt32) -> Data { 144 | var command: linkedit_data_command = data.asStruct() 145 | command.dataoff += offset 146 | return Data(bytes: &command, count: data.commandSize) 147 | } 148 | 149 | private static func updateSymTab(_ data: Data, _ offset: UInt32) -> Data { 150 | var command: symtab_command = data.asStruct() 151 | command.stroff += offset 152 | command.symoff += offset 153 | return Data(bytes: &command, count: data.commandSize) 154 | } 155 | 156 | private static func computeLoadCommandsEditor(_ loadCommandsData: [Data], isDynamic: Bool) -> ((Data, UInt32, UInt32) -> Data) { 157 | 158 | if isDynamic { 159 | return updateDylibFile 160 | } 161 | 162 | var contains_LC_VERSION_MIN_IPHONEOS = false 163 | var contains_LC_BUILD_VERSION = false 164 | for lc in loadCommandsData { 165 | let loadCommand = UInt32(lc.loadCommand) 166 | if loadCommand == LC_VERSION_MIN_IPHONEOS { 167 | contains_LC_VERSION_MIN_IPHONEOS = true 168 | } else if loadCommand == LC_BUILD_VERSION { 169 | contains_LC_BUILD_VERSION = true 170 | } 171 | } 172 | 173 | if contains_LC_VERSION_MIN_IPHONEOS == contains_LC_BUILD_VERSION { 174 | if contains_LC_BUILD_VERSION == true { 175 | fatalError("Bad Mach-O Object file: Both LC_VERSION_MIN_IPHONEOS and LC_BUILD_VERSION are present.\nEither one of them should be present") 176 | } else { 177 | fatalError("Bad Mach-O Object file: does not contain LC_VERSION_MIN_IPHONEOS or LC_BUILD_VERSION.\nEither one of them should be present") 178 | } 179 | } 180 | 181 | if contains_LC_VERSION_MIN_IPHONEOS { 182 | // `offset` is kind of a magic number here, since we know that's the only meaningful change to binary size 183 | // having a dynamic `offset` requires two passes over the load commands and is left as an exercise to the reader 184 | return updatePreiOS12ObjectFile 185 | } else { 186 | return updatePostiOS12ObjectFile 187 | } 188 | } 189 | 190 | 191 | static func updatePostiOS12ObjectFile(lc: Data, minos: UInt32, sdk: UInt32) -> Data { 192 | let cmd = Int32(bitPattern: lc.loadCommand) 193 | switch cmd { 194 | case LC_BUILD_VERSION: 195 | return updateBuildVersion(lc, 0, minos: minos, sdk: sdk) 196 | default: 197 | return lc 198 | } 199 | } 200 | 201 | static func updatePreiOS12ObjectFile(lc: Data, minos: UInt32, sdk: UInt32) -> Data { 202 | // `offset` is kind of a magic number here, since we know that's the only meaningful change to binary size 203 | // having a dynamic `offset` requires two passes over the load commands and is left as an exercise to the reader 204 | let offset = UInt32(abs(MemoryLayout.stride - MemoryLayout.stride)) 205 | let cmd = Int32(bitPattern: lc.loadCommand) 206 | switch cmd { 207 | case LC_SEGMENT_64: 208 | return updateSegment64(lc, offset) 209 | case LC_VERSION_MIN_IPHONEOS: 210 | return replaceVersionMin(lc, offset, minos: minos, sdk: sdk) 211 | case LC_DATA_IN_CODE, LC_LINKER_OPTIMIZATION_HINT: 212 | return updateDataInCode(lc, offset) 213 | case LC_SYMTAB: 214 | return updateSymTab(lc, offset) 215 | default: 216 | return lc 217 | } 218 | } 219 | 220 | static func updateDylibFile(lc: Data, minos: UInt32, sdk: UInt32) -> Data { 221 | // `offset` is kind of a magic number here, since we know that's the only meaningful change to binary size 222 | // having a dynamic `offset` requires two passes over the load commands and is left as an exercise to the reader 223 | let offset = UInt32(abs(MemoryLayout.stride - MemoryLayout.stride)) 224 | let cmd = Int32(bitPattern: lc.loadCommand) 225 | guard cmd != LC_BUILD_VERSION else { 226 | fatalError("This arm64 binary already contains an LC_BUILD_VERSION load command!") 227 | } 228 | if cmd == LC_VERSION_MIN_IPHONEOS { 229 | return replaceVersionMin(lc, offset, minos: minos, sdk: sdk) 230 | } 231 | return lc 232 | } 233 | 234 | 235 | public static func processBinary(atPath path: String, minos: UInt32 = 13, sdk: UInt32 = 13, isDynamic: Bool = false) { 236 | let (headerData, loadCommandsData, programData) = readBinary(atPath: path, isDynamic: isDynamic) 237 | 238 | let editor = computeLoadCommandsEditor(loadCommandsData, isDynamic: isDynamic) 239 | 240 | let editedCommandsData = loadCommandsData 241 | .map { return editor($0, minos, sdk) } 242 | .merge() 243 | 244 | var header: mach_header_64 = headerData.asStruct() 245 | header.sizeofcmds = UInt32(editedCommandsData.count) 246 | 247 | // reassemble the binary 248 | let reworkedData = [ 249 | Data(bytes: &header, count: MemoryLayout.stride), 250 | editedCommandsData, 251 | programData 252 | ].merge() 253 | 254 | // save back to disk 255 | try! reworkedData.write(to: URL(fileURLWithPath: path)) 256 | } 257 | } 258 | --------------------------------------------------------------------------------