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

孙康

前面讲到如何新增一条LC_LOAD_DYLIB指令到二进制,以注入动态库。这种方式有一定的局限性,因为注入的前提是由于 Segment 及 Section 间的内存对齐,存在足够的空白空间可以插入一条动态库依赖指令。如果没有足够的空白空间,则强制添加指令会导致代码段(__text)被覆盖,进而造成二进制打不开或者行为异常的现象。Google Chrome 及 Microsoft Edge 浏览器 Bundle 内的二进制很多没有足够的空白空间,读者可自行验证。

系统库替换优势

那么如果没有足够的空白空间插入新的指令,如何注入动态库?一种比较麻烦的方法是人为构造空白空间:将代码段及其后的二进制数据进行后移,增大二进制的文件大小,以留出足够的空间。这种方法难度大,二进制数据偏移后还须进行修正,如各个 Segment 和 Section 的起始地址、二进制中表示函数或者其他数据地址的数据、二进制中表示地址偏移的数据等。有的 Section 数据需要修正,有的不需要;某些 Section 部分数据需要修正,部分不需要;某些表示偏移大小的数据(需要注意的是这种数据是以 uleb128 进行编码存储的)修正后其占用的空间变大了,还须单独修正。这里不展开说了。

还有一种更为简单的方法:替换libSystem.B.dylib依赖库。在 macOS 上,基本所有的 Mach-o 文件均会依赖libSystem.B.dylib,那么如果把依赖该动态库的加载指令修改为依赖我们自定义的动态库,就不必关心空白空间是否足够了。这需要注意两点:

1 自定义的依赖库路径不能过长;
2 原二进制所使用的来自 libSystem.B.dylib 的符号需要自定义的依赖库导出。

系统库替换实现

第一点目的是避免麻烦。因为字节对齐的原因,/usr/lib/libSystem.B.dylib路径会占用 32 个字节,如下图所示。所以自定义的依赖库路径长度不要大于 31 字节,否则需要对后续二进制内容进行偏移。

libSystem.B.dylib占用空间
libSystem.B.dylib占用空间

修改后的二进制依赖库情况如下图,注入的动态库路径为/usr/local/lib/libinject.dylib,路径长度是 30 字节。这里所举例的二进制只有一个依赖库,那么注入后能否运行?如果你编写的 demo 非常简单,简单到没有依赖libSystem.B.dylib的任何符号,那么即使libinject.dylib是个空壳动态库,修改后的二进制也是可以直接运行的。

libSystem.B.dylib加载指令修改后状态
libSystem.B.dylib加载指令修改后状态

如果修改的二进制比较复杂,那么修改后直接运行该二进制会崩溃,错误信息如下。日志很直白,dyld在链接阶段寻找helloWorld-mod引用的符号_printf,在依赖库libinject.dylib中没有找到。当然是找不到的,因为_printf符号并不在libinject.dylib导出符号里。这个符号本来是libSystem.B.dylib提供的,但因为我们把依赖库修改了,所以链接器不会去libSystem.B.dylib中查找。

1
2
3
4
5
➜  Debug ./helloWorld-mod
dyld[10541]: Symbol not found: _printf
Referenced from: <9562BD19-769A-3AC8-83FD-7912CDB97807> /Users/....../Build/Products/Debug/helloWorld-mod
Expected in: <499C61D6-3325-3E7F-915E-30E2A609A84B> /usr/local/lib/libinject.dylib
[1] 10541 abort ./helloWorld-mod

这就是第二点的注意事项,二进制引用的libSystem.B.dylib的导出符号需要我们自定义的动态库提供。如果修改的二进制的引用符号很简单,直接自己实现一份也可以。但如果引用符号很多,成百上千,而且符号的原型、作用也不清楚,那么只能想办法使用libSystem.B.dylib的导出符号。可执行文件依赖的系统动态库被修改为自定义的动态库libinject.dylib后,如果还想使用libSystem.B.dylib的导出符号,就需要在dyld链接libinject.dylib的时候去链接libSystem.B.dylib。这是完全可行的,因为不止可执行文件,动态库也会依赖libSystem.B.dylib,如下。

1
2
3
4
➜  ~ otool -L /usr/local/lib/libinject.dylib
/usr/local/lib/libinject.dylib:
/usr/local/lib/libinject.dylib (compatibility version 1.0.0, current version 1.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.0.0)

所以libinject.dylib可以间接导出符号,即导出的符号自身没有实现,依赖其他库实现。clang编译器可以使用参数exported_symbols_list指定导出符号表,比如这里我们需要libinject.dylib导出_printf符号,可以使用如下命令实现。如需依赖其他库或者符号,可自行添加。

1
2
3
➜  ~ touch symbols.txt
➜ ~ echo _printf > ./symbols.txt
➜ ~ clang -dynamiclib -o libinject.dylib -exported_symbols_list ./symbols.txt ./inject.c -lSystem.B

此时查看libinject.dylib的导出符号,可以发现_printf符号已被导出。这时再次运行被修改的二进制则可以正常运行。这里使用nm命令打印二进制的引用或导出符号,from libSystem表示符号来源为libSystem.B.dylibindirect表示符号是间接引用或导出的。

1
2
3
4
5
6
7
➜  ~ nm -m ./libinject.dylib
(undefined) external _printf (from libSystem)
(indirect) external _printf (for _printf)

➜ ~ mv ./libinject.dylib /usr/local/lib
➜ ~ ./helloWorld-mod
Hello, World!

符号搜集代码

读者可自行查看系统二进制的引用符号,如zsh等,其引用及导出符号相当可观,大部分的引用符号来源libSystem.B.dylib。那么这类二进制修改系统依赖库为自定义动态库,自定义依赖库就需要导出大量符号。如果我将/bin/sbin/usr/bin/usr/sbin等系统目录下的所有的可执行文件都注入一遍,需要解决多少libSystem.B.dylib导出符号?大概是 2900+。如果纯靠人工搜集符号,过于浪费时间,可使用如下python脚本搜集符号。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import os
import subprocess
import sys

def is_macho_executable(file_path):
output = subprocess.check_output(['file', file_path]).decode('utf-8')
return 'Mach-O' in output and 'executable' in output

def collect_symbols(binary_path, x86_symbol_file_path, arm_symbol_file_path):
nm_output = subprocess.check_output(['nm', '-m', binary_path])
x86_symbols = set()
arm_symbols = set()

lines = nm_output.decode().split('\n')
i = 0
size = len(lines)
while i < size:
if ' (for architecture x86_64)' in lines[i]:
i += 1
while i < size and ' architecture' not in lines[i]:
if ' (from libSystem)' in lines[i]:
symbol = lines[i].split(' ')[19]
x86_symbols.add(symbol)
i += 1

elif ' (for architecture arm64e)' in lines[i] or ' (for architecture arm64)' in lines[i]:
i += 1
while i < size and ' architecture' not in lines[i]:
if ' (from libSystem)' in lines[i]:
symbol = lines[i].split(' ')[19]
arm_symbols.add(symbol)
i += 1

else:
if ' (from libSystem)' in lines[i]:
symbol = lines[i].split(' ')[19]
x86_symbols.add(symbol)
arm_symbols.add(symbol)

i += 1

with open(x86_symbol_file_path, 'r+') as f:
existing_symbols = set(line.strip() for line in f)
new_symbols = existing_symbols | x86_symbols
f.seek(0)
f.truncate()
for line in sorted(new_symbols):
f.write(line + '\n')

with open(arm_symbol_file_path, 'r+') as f:
existing_symbols = set(line.strip() for line in f)
new_symbols = existing_symbols | arm_symbols
f.seek(0)
f.truncate()
for line in sorted(new_symbols):
f.write(line + '\n')

if __name__ == '__main__':
if len(sys.argv) != 4:
print('Usage: python collect_symbols.py <binary_path> <x86_symbol_file_path> <arm_symbol_file_path>')
sys.exit(1)

path = sys.argv[1]
x86_symbol_file_path = sys.argv[2]
arm_symbol_file_path = sys.argv[3]

if os.path.isdir(path):
for root, dirs, files in os.walk(path):
for file in files:
binary_path = os.path.join(root, file)
if is_macho_executable(binary_path):
print("dump symbols for file ", binary_path)
collect_symbols(binary_path, x86_symbol_file_path, arm_symbol_file_path)
elif is_macho_executable(path):
collect_symbols(path, x86_symbol_file_path, arm_symbol_file_path)
else:
print('Invalid binary path')
sys.exit(1)
  • Title: macOS 修改 MachO 文件实现动态库注入(三)
  • Author: 孙康
  • Created at : 2023-06-26 15:12:57
  • Updated at : 2023-08-31 19:55:23
  • Link: https://conradsun.github.io/2023/06b81ddf97.html
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
macOS 修改 MachO 文件实现动态库注入(三)