通过修改 Mach-O 文件解决闭源组件符号冲突问题

# 符号冲突影响程序运行的正确性

这里我以一个例子说明这个问题。工程结构如下:

example
├── foo
│   ├── call_lib_foo.c
│   ├── call_lib_foo.h
│   ├── foo.c
│   └── foo.h
├── main.c
├── my_foo.c
└── my_foo.h

foo 的目录有两个文件,内容分别为:

// foo.c
void foo(void) {
    printf("foo in lib\n");
}

// call_lib_foo.c
void call_lib_foo(void) {
    foo();
}

构建命令如下:

clang -c foo/foo.c -o build/foo/foo.o
clang -c foo/call_lib_foo.c -o build/foo/call_lib_foo.o
ar rcs build/libfoo.a build/foo/foo.o build/foo/call_lib_foo.o

这将为 foo 目录下两个文件编译打包成一个静态库 libfoo.a

根目录内容如下:

// my_foo.c
void foo(void) {
    printf("foo in main\n");
}
// main.c
int main(int argc, const char * argv[]) {
    foo();
    call_lib_foo();
    return 0;
}

构建命令如下:

clang -c my_foo.c -o build/my_foo.o
clang -c main.c -o build/main.o
clang build/my_foo.o build/main.o -lfoo -Lbuild -o build/example

main 函数中,我们先后调用了 foocall_lib_foo,执行后预期输出内容如下:

$ ./build/example
foo in main
foo in lib

预期调用关系为:

main -> foo(main)
main -> call_lib_foo -> foo(lib)

但实际执行结果为:

$ ./build/example
foo in main
foo in main

call_lib_foo 中错误地调用了主工程的 foo 函数,这是因为静态链接器在解决符号定义和引用关系时,每个符号只会加载一个。在例中,选择了主工程的 foo 函数。

这是一个比较常见的情况,多出现在使用外部闭源组件场景,当闭源组件内部使用了一份开源组件的实现,同时我们也用到了该开源组件。如果两份组件因为定制化、版本不同,出现部分实现不同,最终将发现如本例一样的情况,调用了和预期不一致的函数。

由于我们无法修改源码,为冲突的函数添加一个前缀区分开,导致这个问题变得很棘手。我们可以有以下几种策略:

  • 修改我们使用开源组件实现,函数换个名字
  • 将闭源组件转换为动态库集成
  • 直接使用闭源组件内置的实现

事实上我们还有一种解决方式:直接修改 Mach-O,改变闭源组件的符号,避免遇到上述冲突。

# 修改 Mach-O 符号信息

这里简化了很多内容,关于 Mach-O 详细内容可以参阅其他文章。

call_lib_foo.o 为例:

LC_SYMTAB.symtab)记录了 Symbol Table 和 String Table 的位置,以及他们的个数和大小。 这将是我们要重点关心的内容。对应的结构体如下: 定义在 mach-o/loader.h

/*
 * The symtab_command contains the offsets and sizes of the link-edit 4.3BSD
 * "stab" style symbol table information as described in the header files
 * <nlist.h> and <stab.h>.
 */
struct symtab_command {
	uint32_t	cmd;		/* LC_SYMTAB */
	uint32_t	cmdsize;	/* sizeof(struct symtab_command) */
	uint32_t	symoff;		/* symbol table offset */
	uint32_t	nsyms;		/* number of symbol table entries */
	uint32_t	stroff;		/* string table offset */
	uint32_t	strsize;	/* string table size in bytes */
};

不论指令跳转到指定函数,还是重定位表,引用的符号信息都记录在 Symbol Table 中,我们只要找到合适的修改方式即可。

foo 函数的符号信息如下:

对应的结构体定义在 mach-o/nlist.h 中,内容如下:

/*
 * This is the symbol table entry structure for 64-bit architectures.
 */
struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};

可以看到 n_un.n_strx 记录的是符号的字符串表示形式的位置,_foo 符号在 String Table 的 0x0000000A 位置,即第 10 个索引。对应内容如下:

String Table 使用空字符分割不同的字符串,用来表示该字符串读取完毕。 这里是从 5F6F,即 5F 66 6F 6F,刚好对应 _foo

由于 _foo 刚好是 _call_lib_foo 的子串,Mach-O 为了节省空间,直接放到一起了。

从这里可以看到,我们将符号表中的 String Table 的索引指向一个新字符串即可。

替换流程如下:

  1. 读取 Mach-O 文件,先找到 LC_SYMTAB 信息,确定 Symbol Table 和 String Table 信息
  2. 遍历 Symbol Table 找到要修改的符号
  3. 在 String Table 添加新符号的字符串
  4. 将对应的符号记录的索引改为新字符串的起始位置
  5. 最后不要忘了修改 String Table 的 symtab_command.strsize 大小

供参考的示例代码参见:https://github.com/DianQK/change-mach-o-symbol (opens new window)

示例代码每次仅支持修改单个 Mach-O 文件的一个符号,你可以扩展成直接修改一个静态库的形式。

参考内容: