前面已经提到可以通过修改环境变量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文件格式。
需要关注的是Load Commands指令加载部分。其中LC_LOAD_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中的指令数量和大小信息,不然新增的指令不会被解析。
修改完成后,修改后的文件直接覆盖原文件,所以测试前请将原文件备份。