macOS 修改 Mach-o 文件实现动态库注入(一)
前面 已经提到可以通过修改环境变量DYLD_INSERT_LIBRARIES
注入动态库,但这种方法具有一定的局限性,在开启 SIP 的机器上,应用程序可能无法继承该环境变量,导致注入失败。那么有没有局限性更小的注入方法?本文所展示的代码片段来自开源项目 FishHook ,更多细节可参考该项目。
引入 可以找个 Mach-o (Mach Object File Format)文件先分析一下。和 Linux 系统上的 ELF (Extensible Firmware Interface)、Windows 系统上的 PE (Portable Executable) 文件相比,Mach-o 文件结构大体与之类似,可分为 Header、Segment、Section 等部分,使用 MachOView 工具可以方便查看二进制内容。
Mach-o 文件可分为瘦二进制和胖二进制(Fat Binary),即支持多处理器架构的二进制文件,使用file
命令可查看文件支持的架构,如下:
1 2 3 4 5 6 ➜ ~ file /usr/bin/python3 /usr/bin/python3: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64 - Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e - Mach-O 64-bit executable arm64e] /usr/bin/python3 (for architecture x86_64): Mach-O 64-bit executable x86_64 /usr/bin/python3 (for architecture arm64e): Mach-O 64-bit executable arm64e
本文不涉及胖二进制的分析修改,先分析单二进制文件如何修改并注入。有关 Mach-o 文件格式的讲解请自行百度/谷歌。如下图是一个简单的仅支持 x86_64 架构的 Mach-o 文件格式。单架构Mach-o文件格式
需要关注的是 Load Commands 指令加载部分。其中LC_LOAD_DYLIB
指令表示该二进制依赖的动态库信息,如所有二进制均依赖的动态库 libSystem.B.dylib 就在其中。libSystem.B.dylib加载指令
那么如果注入动态库就需要修改 Load Commands,添加一条LC_LOAD_DYLIB
的指令,将待添加的动态库名称填入。需要特别注意的是,添加的指令区域必须是该二进制的空白区。因为内存对齐的原因,每个 Segment 之间会有一段空白区,添加的指令内容需写到 Load Commands 最后一条指令之后、TEXT Section 之前的区域。多数情况下这部分区域是足够容纳要添加的内容的,如果不够则不能写入。
动态库注入分析 在进行注入前首先需要判断文件格式,是否为可执行文件、是否为胖二进制,进一步分析是 64bit 还是 32bit 可执行文件。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 private func signAdhoc () { let task = Process () task.executableURL = URL (fileURLWithPath: "/usr/bin/codesign" ) task.arguments = ["-f" , "-s" , "-" , binaryPath] try? task.run() } func initWithFile (filePath : String , libPath : String ) -> Bool { if ! FileManager .default.isExecutableFile(atPath: filePath) { print ("File to be modified is not Executable." ) return false } guard let data = FileManager .default.contents(atPath: filePath) else { print ("Failed to obtain contents for file." ) return false } binaryPath = filePath dylibPath = libPath machOData = data return true } func repackBinary () -> Bool { if machOData.isEmpty { return false } return machOData.withUnsafeBytes { pointer in guard let header = pointer.bindMemory(to: fat_header.self ).baseAddress else { print ("Failed to get fat header pointer." ) return false } var result = false switch header.pointee.magic { case MH_MAGIC_64 , MH_CIGAM_64 , MH_MAGIC , MH_CIGAM : result = processThinMachO(offset: 0 ) default : print ("Unknown MachO format." ) return false } signAdhoc() return result } }
这里的signAdhoc
是为了给修改后的二进制签名,因为修改后二进制的内容发生了更改,不重新签名校验无法通过,系统会禁止执行。进行二进制注入的关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 private func injectDylib (header : mach_header, offset : UInt64 , is64bit : Bool ) -> Bool { guard let fileHandle = FileHandle (forWritingAtPath: binaryPath) else { print ("Failed to create handler for binary file." ) return false } let pathSize = (dylibPath.count & ~ (pathPadding - 1 )) + pathPadding let cmdSize = MemoryLayout <dylib_command>.size + pathSize var cmdOffset: UInt64 = 0 var dylibCmd = dylib_command() if is64bit { cmdOffset = offset + UInt64 (MemoryLayout <mach_header_64>.size) } else { cmdOffset = offset + UInt64 (MemoryLayout <mach_header>.size) } dylibCmd.cmd = UInt32 (LC_LOAD_DYLIB ) dylibCmd.cmdsize = UInt32 (cmdSize) dylibCmd.dylib.name = lc_str(offset: UInt32 (MemoryLayout <dylib_command>.size)) try? fileHandle.seek(toOffset: cmdOffset + UInt64 (header.sizeofcmds)) fileHandle.write(Data (bytes: & dylibCmd, count: MemoryLayout <dylib_command>.size)) fileHandle.write(dylibPath.data(using: .utf8)! ) var newHeader = header newHeader.ncmds = newHeader.ncmds + 1 newHeader.sizeofcmds = newHeader.sizeofcmds + UInt32 (cmdSize) try? fileHandle.seek(toOffset: offset) fileHandle.write(Data (bytes: & newHeader, count: MemoryLayout <mach_header>.size)) try? fileHandle.close() return true } private func processThinMachO (offset : Int ) -> Bool { let thinData = machOData.advanced(by: offset) return thinData.withUnsafeBytes { pointer in guard let header = pointer.bindMemory(to: mach_header.self ).baseAddress else { print ("Failed to get mach header pointer." ) return false } switch header.pointee.magic { case MH_MAGIC_64 , MH_CIGAM_64 : return injectDylib(header: header.pointee, offset: UInt64 (offset), is64bit: true ) case MH_MAGIC , MH_CIGAM : return injectDylib(header: header.pointee, offset: UInt64 (offset), is64bit: false ) default : print ("Unknown MachO format." ) return false } } }
processThinMachO
仅分析二进制的格式是 64bit 还是 32bit,injectDylib
用于添加依赖库。首先生成了dylibCmd
动态库加载指令,指令中的cmdsize
对 dylib 的路径长度进行了 8 字节对齐取整。指令插入的位置为 Header+CmdsSize 之后,即 Load Commands 最后一条指令之后,这里没有判断是否可以插入。注意,还需要修改 Header 中的指令数量和大小信息,不然新增的指令不会被解析。
修改完成后,修改后的文件直接覆盖原文件,所以测试前请将原文件备份。