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文件格式
单架构Mach-o文件格式

需要关注的是 Load Commands 指令加载部分。其中LC_LOAD_DYLIB指令表示该二进制依赖的动态库信息,如所有二进制均依赖的动态库 libSystem.B.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 中的指令数量和大小信息,不然新增的指令不会被解析。

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

  • Title: macOS 修改 Mach-o 文件实现动态库注入(一)
  • Author: 孙康
  • Created at : 2023-06-25 13:12:57
  • Updated at : 2023-08-31 19:55:30
  • Link: https://conradsun.github.io/2023/0686aa5ea2.html
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
macOS 修改 Mach-o 文件实现动态库注入(一)