macOS修改MachO文件实现动态库注入(一)

前面已经提到可以通过修改环境变量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命令可查看文件支持的架构,如下:

➜  ~ 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可执行文件。代码如下:

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是为了给修改后的二进制签名,因为修改后二进制的内容发生了更改,不重新签名校验无法通过,系统会禁止执行。进行二进制注入的关键代码如下:

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中的指令数量和大小信息,不然新增的指令不会被解析。

修改完成后,修改后的文件直接覆盖原文件,所以测试前请将原文件备份。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容