通用内存快照裁剪压缩库Tailor介绍及源码分析(一)

server/2024/10/19 14:07:08/

背景

我们知道内存快照是治理 OOM 问题及其他类型的内存问题的重要数据源,内存快照中保存了进程虚拟机的完整的堆内存数据,很多时候也是调查其他类型异常的重要参考。但是dump出来的堆转储文件.hprof往往很大,以 LargeHeap 应用为例,其 OOM 时的内存快照大小通常在512M左右,要有效的存储和获取都是一个问题。

线下拿到hprof文件相对容易,也可以预防OOM,但覆盖的场景十分有限,很多问题往往是线上海量用户在各种场景下发生的,如何高效稳定收集到线上的内存快照,这对于一套相对完整的通用异常数据搜集系统而言是至关重要的一环。我们需要在APP中实现一套回捞机制,把线上生成的hprof文件回传到云端。然而传输过程对于数据大小是十分敏感的,首当其冲就是流量消耗问题,其次,更小的快照传输耗时更少,回传成功率也会大幅提升,所以需要对一些OOM分析无关紧要的数据进行裁剪,从而减小用于传输的hprof数据大小。内存快照是虚拟机堆内存数据的完整 copy,这其中可能包含有账号、Token、联系人、密钥以及其他可能存在隐私的图片/字符串等,隐私数据是必须要裁剪掉的,否则会存在隐私安全问题。

目前已知的裁剪方案有种:一种是已开源的 Matrix 方案,另一种是西瓜视频Android团队开源的hprof 流裁剪方案。

Matrix 方案分为两步:先通过 Debug.dumpHprofData 直接 dump 出一个完整的 hprof 文件;然后通过分析 hprof 文件分别裁剪掉数据相同的 Bitmap 对象和 String 对象。其裁剪方案存在以下问题:

    原生接口直接 dump 出的 hprof 文件过大,存储问题不好解决;
    裁剪过程中涉及到大文件 I/O 和 hprof 分析,对 App 性能的影响不好控制;
    裁剪不彻底,快照中仍然存在大量无用数据和可能存在的隐私问题。

hprof 流裁剪则是基于 hprof 文件格式,在 hprof 文件写入过程中进行裁剪压缩,存储空间问题大幅改善,也没有大文件 I/O 和 hprof 分析过程带来的性能问题。

由西瓜Android团队开发的Tailor正是一款通用内存快照裁剪压缩工具,通过它可以在异常时直接dump出一个迷你内存快照。快照中没 有任何敏感信息,更重要的是文件非常小的同时数据也相对完整,非常适合离线分析OOM及其他类型异常的调查定位。

流裁剪压缩实现原理

如果只为了治理 OOM,可以进行最大化裁剪(如byte[]、char[]、boolean[]、short[]、float[]、int[]、double[]、long[]、hprof格式裁剪),甚至可以只保留 app-heap。但作为通用异常数据,我们可能也会在必要的时候,通过回捞快照来分析非 OOM 类的异常,甚至是 native 异常。随着稳定性治理的深入,快照更多是用来分析非 OOM 异常。对于非 OOM 异常,快照的完整性尤为重要,同时非 OOM 的 crash 堆内存通常较小,最大化裁剪没有必要,综合考虑下只进行 byte[]、char[] 和 hprof 格式裁剪。

快照 dump 的过程大致可以分为 5 步,Tailor 只关注 open 和 write 环节。通过 xHook [3](针对 Android 平台 ELF 的 PLT hook库,PLT hook 的本质是修改内存中PLT表对应的GOT条目保存的地址值, 来实现跳转到自定义函数的。在加载动态库的时候,动态库的每一个外部函数都会在PLT表中有一个条目,是一小段可执行代码,外部函数的调用都是在调用 PLT 表里的条目,然后 PLT 会无条件跳转到对应的GOT条目保存的地址。具体参见另一篇文章【动态链接中的PLTGOT原理】)在 native 层 hook dump 过程必然会调用到的 open/write 函数,以此实现对hprof 文件写入流的代理,进而进行 hprof 流裁剪。为了进一步降低写入后的文件体积,Tailor 会在裁剪之后直接进行 zlib 流压缩。流程大致如下:

. 调用 Tailor.dumpHprofData() 时,会依次调用 nOpenProxy()、Debug.dumpHprofData()、nCloseProxy();

. nOpenProxy 在 native 层开启对 int open(const char* __path, int __flags, ...)和 ssize_t write_proxy(int fd, const char*buffer, size_t count) 的 hook 代理;

. Debug.dumpHprofData 执行中会先调到 nOpenProxy 函数,hook 代理逻辑会过滤出目标文件的 fd;调到 write 函数时 hook 代理逻辑通过 fd 过滤出目标文件的写入数据进行裁剪压缩;

. nCloseProxy 逻辑清除之前的 hook 代理。

// isGzip 是否在裁剪之后进行zip压缩
public static synchronized void dumpHprofData(String fileName, boolean isGzip) throws IOException {nOpenProxy(fileName, isGzip);Debug.dumpHprofData(fileName);nCloseProxy();
}

如图所示

 

源码分析 

当我们需要堆转储时,原本方法是调用 Debug.dumpHprofData(fileName),现在调用Tailor.dumpHprofData(fileName, isGzip)即可。

接下来从源码看一下Tailor是如何hook 内存快照的open 和 write 来实现裁剪和压缩的。

//Tailor.java
// isGzip 是否在裁剪之后进行zip压缩
public static synchronized void dumpHprofData(String targetPath, boolean isGzip) throws IOException {nOpenProxy(targetPath, isGzip);Debug.dumpHprofData(targetPath);nCloseProxy();
}

堆转储开始前启用裁剪:

//xloader.cpp
void Tailor_nOpenProxy(JNIEnv* env, jobject obj, jstring name, jboolean gzip) {target = -1;reader = new ByteReader();writer = createWriter(env->GetStringUTFChars(name, 0), gzip);fill(writer, const_cast<char *>(VERSION), 18);LOGGER(">>> open %s", ((0 == hook()) ? "success" : "failure"));
}

创建一个ByteReader,后面会用来接收dump的字节流。根据是否需要gzip创建对应的Writer,负责将ByteReader中经过裁剪的数据写到hprof文件。

dump的默认行为是先open一个文件,然后往文件里写内存快照数据,如果我们只hook write方法,我们没法判断当前在写数据的fd就是hprof文件。

ssize_t write_proxy(int fd, const char *buffer, size_t count) {if (target == fd) {//fd是open_proxy返回的target,进行裁剪处理return handle(buffer, count);} else {//原样输出return write(fd, buffer, count);}
}

我们也必须hook open操作, 如果启用了裁剪,并且打开文件path和nOpenProxy传入的targetPath一致,那么就可以确定是在dump hprof文件。

int open_proxy(const char *path, int flags, mode_t mode) {if (writer != nullptr && strcmp(writer->name, path) == 0) {// w模式打开代理文件%path%.proxyreturn target = writer->proxy(flags, mode);} else {//原样打开return open(path, flags, mode);}
}

记录下打开的target。注意必须返回一个有效的fd给open调用者,这里打开的是加了.proxy后缀的hprof文件,因为我们的Writer构造函数打开了实际的hprof文件。

多个进程不能以写入模式打开同一个文件,否则打开操作就会失败,没法下一步。

堆转储完成后关闭裁剪

void Tailor_nCloseProxy(JNIEnv *env, jobject obj) {delete reader;reader = nullptr;delete writer;writer = nullptr;xh_core_clear();
}

释放reader和writer,并置为nullptr,在open_proxy的时候就不会走到打开代理文件了,write_proxy原样输出,相当于没有hook。 

 

hook过程

hook 进程虚拟内存中动态链接的.so的open和write函数,替换为自定义的open_proxy和write_proxy实现。

xh_core_register负责注册需要hook的so文件和符号,通过正则表达匹配动态链接的so文件的路径名称,满足匹配的都是需要hook的。

自身这个hook库中open_proxy会调用原open函数,如果进行hook的话,就变成递归调用open_proxy了,所以必须要忽略掉,对于要忽略的hook,使用xh_core_ignore函数进行添加。

int hook() {int state = 0;state |= xh_core_register(".*\\.so$", "open", (void *) open_proxy, nullptr);state |= xh_core_register(".*\\.so$", "write", (void *) write_proxy, nullptr);state |= xh_core_ignore(".*libtailor\\.so$", "open");state |= xh_core_ignore(".*libtailor\\.so$", "write");state |= xh_core_refresh(0);return state;
}

1.先看一下注册逻辑

//xh_core.c
int xh_core_register(const char *pathname_regex_str, const char *symbol,void *new_func, void **old_func)
{xh_core_hook_info_t *hi;regex_t              regex;if(NULL == pathname_regex_str || NULL == symbol || NULL == new_func) return XH_ERRNO_INVAL;if(xh_core_inited){XH_LOG_ERROR("do not register hook after refresh(): %s, %s", pathname_regex_str, symbol);return XH_ERRNO_INVAL;}if(0 != regcomp(&regex, pathname_regex_str, REG_NOSUB)) return XH_ERRNO_INVAL;if(NULL == (hi = malloc(sizeof(xh_core_hook_info_t)))) return XH_ERRNO_NOMEM;//1. hi 保存符号if(NULL == (hi->symbol = strdup(symbol))){free(hi);return XH_ERRNO_NOMEM;}
#if XH_CORE_DEBUGif(NULL == (hi->pathname_regex_str = strdup(pathname_regex_str))){free(hi->symbol);free(hi);return XH_ERRNO_NOMEM;}
#endif// 2. hi 保存path 正则表达式hi->pathname_regex = regex;// 3. hi 保存新函数指针hi->new_func = new_func;// 4. hi 保存旧函数指针hi->old_func = old_func;pthread_mutex_lock(&xh_core_mutex);TAILQ_INSERT_TAIL(&xh_core_hook_info, hi, link);pthread_mutex_unlock(&xh_core_mutex);return 0;
}

主要就是最后一句TAILQ_INSERT_TAIL(&xh_core_hook_info, hi, link);

xh_core_hook_info
是一个xh_core_hook_info_queue_t 结构体类型,用于表示一个尾队列的头节点,该队列专门用于存储 xh_core_hook_info 类型的元素,这里把hi 添加到xh_core_hook_info的末尾。

static xh_core_hook_info_queue_t   xh_core_hook_info   = TAILQ_HEAD_INITIALIZER(xh_core_hook_info); 

typedef struct xh_core_hook_info_queue {struct xh_core_hook_info * tqh_first; /* 指向队列中的第一个元素, 如果队列为空,则这个指针应该为 NULL。这是访问队列中第一个元素的标准方式*/struct xh_core_hook_info * *tqh_last; /* 它是一个指向指针的指针,指向队列中最后一个元素中的tqe_next指针。如果队列为空,这个指针可能指向 tqh_first,或者有一个特定的空值或标志来表示队列为空。这种设计允许在 O(1) 时间内向队列的末尾添加新元素,因为可以直接修改 *tqh_last 来指向新元素。*/
} xh_core_hook_info_queue_t;

 通过TAILQ_HEAD_INITIALIZER宏初始化了一个空队列xh_core_hook_info

#define TAILQ_HEAD_INITIALIZER(head)                                    \{ NULL, &(head).tqh_first }

如果队列为空,则 tqh_last 通常会指向 tqh_first 的地址,因为当队列为空时,没有元素可以指向,但我们需要一个地方来存储指向 NULL 的指针的地址,以便知道何时队列为空。 

hi指针指向xh_core_hook_info_t,xh_core_hook_info_t用来存储hook信息:

typedef struct xh_core_hook_info
{
#if XH_CORE_DEBUGchar     *pathname_regex_str;
#endifregex_t   pathname_regex; //hook文件的path 正则表达式char     *symbol;                  //hook文件中的符号名称void     *new_func;               //替换函数指针void    **old_func;                //存储原函数指针地址TAILQ_ENTRY(xh_core_hook_info,) link; //双向链表
} xh_core_hook_info_t;

 link成员负责维系链表结构

struct {struct xh_core_hook_info * tqe_next;  /* 指向队列中下一个元素的指针。 */ struct xh_core_hook_info * *tqe_prev; /* 指向队列中前一个元素中tqe_next指针的指针。这个设计允许在O(1)时间内从队列中移除任何节点,因为可以直接修改前一个节点的tqe_next指针,而无需遍历链表。 */ 
} link;

 插入队列尾,修改link链表

#define TAILQ_INSERT_TAIL(head, elm, field) do {                        \TAILQ_NEXT((elm), field) = NULL;                                \(elm)->field.tqe_prev = (head)->tqh_last;                       \*(head)->tqh_last = (elm);                                      \(head)->tqh_last = &TAILQ_NEXT((elm), field);                   \} while (0)#define TAILQ_NEXT(elm, field) ((elm)->field.tqe_next)

2. 再看xhook_refresh 函数 

int xh_core_refresh(int async)
{//initxh_core_init_once();if(!xh_core_init_ok) return XH_ERRNO_UNKNOWN;if(async){//init for asyncxh_core_init_async_once();if(!xh_core_async_init_ok) return XH_ERRNO_UNKNOWN;//refresh asyncpthread_mutex_lock(&xh_core_mutex);xh_core_refresh_thread_do = 1;pthread_cond_signal(&xh_core_cond);pthread_mutex_unlock(&xh_core_mutex);}else{//refresh syncpthread_mutex_lock(&xh_core_refresh_mutex);xh_core_refresh_impl();pthread_mutex_unlock(&xh_core_refresh_mutex);}return 0;
}

核心是 xh_core_refresh_impl

这个方法比较大,下面仅保留核心的部分逻辑。

static void xh_core_refresh_impl()
{
......
//读取/proc/self/maps,获取内存映射信息if(NULL == (fp = fopen("/proc/self/maps", "r"))){XH_LOG_ERROR("fopen /proc/self/maps failed");return;}while(fgets(line, sizeof(line), fp)){/*从一行数据从分别获取addr offset 等信息文件中的每一行代表一个虚拟内存区域,其格式通常如下:7f68caa1c000-7f68cabd6000 r-xp 00000000 08:01 136253   /lib/x86_64-linux-gnu/libc-2.19.so所处虚拟内存地址(VMA)范围‌:起始地址-结束地址。VMA权限‌:表示该内存区域的访问权限,r=读,w=写,x=,s=共享,p=私有。偏移量‌:表示VMA对应的segment在映像文件中的偏移, 偏移值为0表示一个映射内存区域的开始。主设备号和次设备号‌:08:01。映像文件的节点号inode:136253映像文件的路径‌:如果是文件映射,这里会显示文件的路径名。对于匿名映射(如堆和栈)则没有路径名,比如下面这两个segment:0060c000-0062d000  rw-p 00000000 00:00 0   [heap]  # 堆区7f68cb005000-7f68cb006000 rw-p 00000000 00:00 0  # 路径为空*/if(sscanf(line, "%"PRIxPTR"-%*lx %4s %lx %*x:%*x %*d%n", &base_addr, perm, &offset, &pathname_pos) != 3) continue;//check permissionif(perm[0] != 'r') continue;if(perm[3] != 'p') continue; //do not touch the shared memory//check offset////We are trying to find ELF header in memory.//It can only be found at the beginning of a mapped memory regions//whose offset is 0.if(0 != offset) continue;...//获取pathname 指针位置pathname = line + pathname_pos;pathname_len = strlen(pathname);....// 这里是检查 pathname 是否在要hook的queue里,如果是就继续走下面TAILQ_FOREACH(hi, &xh_core_hook_info, link) //find hook info{if(0 == regexec(&(hi->pathname_regex), pathname, 0, NULL, 0)){TAILQ_FOREACH(ii, &xh_core_ignore_info, link) //find ignore info{if(0 == regexec(&(ii->pathname_regex), pathname, 0, NULL, 0)){if(NULL == ii->symbol)goto check_finished;//pathname和symbol在忽略队列里,不对该.so文件做hookif(0 == strcmp(ii->symbol, hi->symbol)) goto check_continue;}}match = 1;check_continue:break;}}
check_finished:if(0 == match) continue;....//当前so需要hook,检查是否已存在对应的map info,首次检查肯定是走到else, if就先略mi_key.pathname = pathname;if(NULL != (mi = RB_FIND(xh_core_map_info_tree, &xh_core_map_info, &mi_key))) {......}else{//不存在创建一个新的 map infoif(NULL == (mi = (xh_core_map_info_t *)malloc(sizeof(xh_core_map_info_t)))) continue;if(NULL == (mi->pathname = strdup(pathname))){free(mi);continue;}mi->base_addr = base_addr;//repeated?//We only keep the first one, that is the real base addressif(NULL != RB_INSERT(xh_core_map_info_tree, &map_info_refreshed, mi)){
#if XH_CORE_DEBUGXH_LOG_DEBUG("repeated map info when create: %s", line);
#endiffree(mi->pathname);free(mi);continue;}//hookxh_core_hook(mi); //hook}}
}

xh_core_map_info_t的对象记录pathname,so基地址base_addr的信息,然后就调用到xh_core_hook, 再调用xh_core_hook_impl。

static void xh_core_hook_impl(xh_core_map_info_t *mi)
{//init 根据elf被加载到内存中的基地址,解析获得一个elf对象,这个elf对象协助我们在内存中定位要hook的位置,下面会进一步介绍获取逻辑。if(0 != xh_elf_init(&(mi->elf), mi->base_addr, mi->pathname)) return;//hookxh_core_hook_info_t   *hi;xh_core_ignore_info_t *ii;int ignore;// 这里很简单就是看一下白名单,有没有要忽略的TAILQ_FOREACH(hi, &xh_core_hook_info, link) //find hook info{if(0 == regexec(&(hi->pathname_regex), mi->pathname, 0, NULL, 0)){ignore = 0;TAILQ_FOREACH(ii, &xh_core_ignore_info, link) //find ignore info{if(0 == regexec(&(ii->pathname_regex), mi->pathname, 0, NULL, 0)){if(NULL == ii->symbol) //ignore all symbolsreturn;if(0 == strcmp(ii->symbol, hi->symbol)) //ignore the current symbol{ignore = 1;break;}}}//不忽略就xh_elf_hookif(0 == ignore)xh_elf_hook(&(mi->elf), hi->symbol, hi->new_func, hi->old_func);}}
}

 

elf文件结构

先了解一下elf文件的基本信息,有助于理解xh_elf_init和后续的xh_elf_hook
libtest.so 的 ELF 文件头信息:

$ llvm-readelf -h libtest.so

ELF 文件的起始处,有一个固定格式的定长的文件头(32 位架构为 52 字节,64 位架构为 64 字节)。ELF 文件头以 magic number 0x7F 0x45 0x4C 0x46 开始(其中后 3 个字节分别对应可见字符 E L F)。

ELF 文件头中包含了 SHT(section header table) 和 PHT(program header table) 在当前 ELF 文件中的起始位置和长度。例如,libtest.so 的 SHT 起始位置为 2980,长度 40 字节;PHT 起始位置为 52,长度 32字节。

SHT(section header table

ELF 以 section 为单位来组织和管理各种信息。ELF 使用 SHT 来记录所有 section 的基本信息。主要包括:section 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等。

libtest.so 的 SHT:

$ llvm-readelf -S libtest.so

 

PHT(program header table) 

ELF 被加载到内存时,是以 segment 为单位的。一个 segment 包含了一个或多个 section。ELF 使用 PHT 来记录所有 segment 的基本信息。主要包括:segment 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等。

libtest.so 的 PHT:

所有类型为 PT_LOAD 的 segment 都会被动态链接器(linker)映射(mmap)到内存中,这个elf文件有三个segment会被加载,segment1的Offset为0,表示elf文件从首字节开始,长度为FileSiz(0x004ec)字节的这段数据。

连接视图(Linking View)和执行视图(Execution View

  • 连接视图:ELF 未被加载到内存执行前,以 section 为单位的数据组织形式。
  • 执行视图:ELF 被加载到内存后,以 segment 为单位的数据组织形式。

我们关心的 hook 操作,属于动态形式的内存操作,因此主要关心的是执行视图,即 ELF 被加载到内存后,ELF 中的数据是如何组织和存放的。

.dynamic section 

这是一个十分重要和特殊的 section,其中包含了 ELF 中其他各个 section 的内存位置等信息。在执行视图中,总是会存在一个类型为 PT_DYNAMIC 的 segment,这个 segment 就包含了 .dynamic section 的内容。

无论是执行 hook 操作时,还是动态链接器执行动态链接时,都需要通过 PT_DYNAMIC segment 来找到 .dynamic section 的内存位置,再进一步读取其他各项 section 的信息。

libtest.so 的 .dynamic section:

$ llvm-readelf -d libtest.so

Dynamic section at offset 0x64c contains 28 entries:

  Tag        Type           Name/Value

  0x00000001 (NEEDED)       Shared library: [liblog.so]

  0x00000001 (NEEDED)       Shared library: [libz.so]

  0x00000001 (NEEDED)       Shared library: [libc++_shared.so]

  0x00000001 (NEEDED)       Shared library: [libm.so]

  0x00000001 (NEEDED)       Shared library: [libdl.so]

  0x00000001 (NEEDED)       Shared library: [libc.so]

  0x0000001e (FLAGS)        BIND_NOW 

  0x6ffffffb (FLAGS_1)      NOW 

  0x00000011 (REL)          0x364

  0x00000012 (RELSZ)        32 (bytes)

  0x00000013 (RELENT)       8 (bytes)

  0x6ffffffa (RELCOUNT)     3

  0x00000017 (JMPREL)       0x384

  0x00000002 (PLTRELSZ)     24 (bytes)

  0x00000003 (PLTGOT)       0x2730

  0x00000014 (PLTREL)       REL

  0x00000006 (SYMTAB)       0x1ec

  0x0000000b (SYMENT)       16 (bytes)

  0x00000005 (STRTAB)       0x2ec

  0x0000000a (STRSZ)        120 (bytes)

  0x6ffffef5 (GNU_HASH)     0x28c

  0x00000004 (HASH)         0x2ac

  0x0000001a (FINI_ARRAY)   0x2644

  0x0000001c (FINI_ARRAYSZ) 8 (bytes)

  0x6ffffff0 (VERSYM)       0x25c

  0x6ffffffe (VERNEED)      0x26c

  0x6fffffff (VERNEEDNUM)   1

  0x00000000 (NULL)         0x0

xh_elf_init获取elf对象信息
int xh_elf_init(xh_elf_t *self, uintptr_t base_addr, const char *pathname)
{...//always reset
memset(self, 0, sizeof(xh_elf_t));//elf文件路径
self->pathname = pathname; //elf加载到进程虚拟内存区域中的起始地址
self->base_addr = (ElfW(Addr))base_addr;//ehdr是ELF头指针,指向这个地址
self->ehdr = (ElfW(Ehdr) *)base_addr; 
//elf起始地址+ELF头信息中PHT偏移值算出运行时的PHT起始地址,并将phdr指针指向这个地址,有时会有segment fault
self->phdr = (ElfW(Phdr) *)(base_addr + self->ehdr->e_phoff); //elf中找offset 是0的 load segment1
ElfW(Phdr) *phdr0 = xh_elf_get_first_segment_by_type_offset(self, PT_LOAD, 0);/* 
save load bias addr
load segment1虚拟内存相对地址VirtAddr一般都是0,映射到内存中正好就是从分配的elf基地址base_addr开始,这里是针对不为0的情况, 需要使用基地址-VirtAddr进行偏差修正,segment1必须从base_addr开始,之后bias_addr + 相对偏移地址才是实际地址。
*/
if(self->base_addr < phdr0->p_vaddr) return XH_ERRNO_FORMAT;
self->bias_addr = self->base_addr - phdr0->p_vaddr;//找 dynamic segment
ElfW(Phdr) *dhdr = xh_elf_get_first_segment_by_type(self, PT_DYNAMIC);//解析 dynamic segment, 先找到 .dynamic section
self->dyn          = (ElfW(Dyn) *)(self->bias_addr + dhdr->p_vaddr);
...
}
解析dynamic segment

先找到 .dynamic section,然后根据.dynamic信息找出hook涉及到的sections在内存中的地址,比如下面这几个:

.rel.dyn .rel.plt包含需要重定位的符号信息,定位的内存区域就是我们要hook的地方。

查找一个符号的相关信息会在三块区域出现:.dynsym 动态符号表,.dynstr 字符表,.gnu.hash 动态链接符号散列表

.gnu.hash是用于符号表查找的一种优化结构。

0x00000011 (REL)          0x364
0x00000012 (RELSZ)        32 (bytes)
对应.rel.dyn section的内存偏移地址和文件大小:
 [Nr] Name              Type            Address  Off    Size   ES Flg Lk Inf Al
 [ 8] .rel.dyn          REL             00000364 000364 000020 08   A  2   0  4

0x00000017 (JMPREL)       0x384
0x00000002 (PLTRELSZ)     24 (bytes)
对应.rel.plt section的内存偏移地址和文件大小:
[Nr] Name              Type            Address  Off    Size   ES Flg Lk Inf Al
[ 9] .rel.plt          REL             00000384 000384 000018 08  AI  2  18  4

0x00000006 (SYMTAB)       0x1ec
0x00000005 (STRTAB)       0x2ec
分别对应.dynsym 和.dynstr :
[ 2] .dynsym           DYNSYM        000001ec 0001ec 000070 10   A  7   1 
[ 7] .dynstr           STRTAB          000002ec 0002ec 000078 00   A  0   0  1

0x6ffffef5 (GNU_HASH)     0x28
对应.gnu.hash section的内存偏移地址和文件大小:
 [ 5] .gnu.hash         GNU_HASH    0000028c 00028c 000020 00   A  2   0  4

 

查找符号和hook 
int xh_elf_hook(xh_elf_t *self, const char *symbol, void *new_func, void **old_func)
{...// 1. 根据symbol名称查找symbol索引// 去bucket 和chain 中找到symbol对应的索引//find symbol index by symbol nameif(0 != (r = xh_elf_find_symidx_by_name(self, symbol, &symidx))) return 0;//2. 对.rel(a).plt对应的got位置的数据进行替换//replace for .rel(a).pltif(0 != self->relplt){xh_elf_plain_reloc_iterator_init(&plain_iter, self->relplt, self->relplt_sz, self->is_use_rela);while(NULL != (rel_common = xh_elf_plain_reloc_iterator_next(&plain_iter))){if(0 != (r = xh_elf_find_and_replace_func(self,(self->is_use_rela ? ".rela.plt" : ".rel.plt"), 1,symbol, new_func, old_func,symidx, rel_common, &found))) return r;if(found) break;}}//replace for .rel(a).dynif(0 != self->reldyn){xh_elf_plain_reloc_iterator_init(&plain_iter, self->reldyn, self->reldyn_sz, self->is_use_rela);while(NULL != (rel_common = xh_elf_plain_reloc_iterator_next(&plain_iter))){if(0 != (r = xh_elf_find_and_replace_func(self,(self->is_use_rela ? ".rela.dyn" : ".rel.dyn"), 0,symbol, new_func, old_func,symidx, rel_common, NULL))) return r;}}//replace for .rel(a).androidif(0 != self->relandroid){xh_elf_packed_reloc_iterator_init(&packed_iter, self->relandroid, self->relandroid_sz, self->is_use_rela);while(NULL != (rel_common = xh_elf_packed_reloc_iterator_next(&packed_iter))){if(0 != (r = xh_elf_find_and_replace_func(self,(self->is_use_rela ? ".rela.android" : ".rel.android"), 0,symbol, new_func, old_func,symidx, rel_common, NULL))) return r;}}return 0;
}
查找符号

根据symbol名称查找symbol索引

static int xh_elf_find_symidx_by_name(xh_elf_t *self, const char *symbol, uint32_t *symidx)
{if(self->is_use_gnu_hash) //当存在.gnu.hash,就使用该表来检索return xh_elf_gnu_hash_lookup(self, symbol, symidx);elsereturn xh_elf_hash_lookup(self, symbol, symidx);
}static int xh_elf_gnu_hash_lookup(xh_elf_t *self, const char *symbol, uint32_t *symidx)
{if(0 == xh_elf_gnu_hash_lookup_def(self, symbol, symidx)) return 0;if(0 == xh_elf_gnu_hash_lookup_undef(self, symbol, symidx)) return 0;return XH_ERRNO_NOTFND;
}

大致过程是首先从.gnu.hash中进行查询,得到该符号在动态符号表.dynsym中的偏移。根据这个偏移读出一个符号,并找到这个符号的名字在字符表.dynstr中的偏移。从字符表中读出符号的名称如果与要查找的符号匹配,则找到了这个符号。

static int xh_elf_gnu_hash_lookup_def(xh_elf_t *self, const char *symbol, uint32_t *symidx)
{
//使用GNU哈希函数生成符号的32位哈希uint32_t hash = xh_elf_gnu_hash((uint8_t *)symbol);//一个掩码的尺寸,单位是位(bits)static uint32_t elfclass_bits = sizeof(ElfW(Addr)) * 8;
//使用布隆过滤器检查符号是否存在,计算符号应该使用第几个Bloom filter掩码word,通常gnu hash只有一个掩码,掩码包含了所有导出符号的掩码位size_t word = self->bloom[(hash / elfclass_bits) % self->bloom_sz];
//计算运行时符号掩码位masksize_t mask = 0| (size_t)1 << (hash % elfclass_bits)| (size_t)1 << ((hash >> self->bloom_shift) % elfclass_bits);//if at least one bit is not set, this symbol is surely missing//测试符号掩码位和word,只要一位没有匹配,查询的符号在当前elf对象中就不存在if((word & mask) != mask) return XH_ERRNO_NOTFND;//bucket[n]的值i是动态符号表相同键排序的符号的最低的索引
//从此处开始递归匹配符号名称和hash值。
//ignore STN_UNDEF, self->symoffset是hash表可访问.dynsym的第一个符号的索引,小于该值就表示符号不存在uint32_t i = self->bucket[hash % self->bucket_cnt];if(i < self->symoffset) return XH_ERRNO_NOTFND;//loop through the chainwhile(1){//.dynsym第i项在.dynstr的偏移st_name,得到名称字符串const char     *symname = self->strtab + self->symtab[i].st_name;//从哈希链取hash,因为【i】是从self->symoffset开始的,这里减掉self->symoffset从0索引const uint32_t  symhash = self->chain[i - self->symoffset];//.dynsym中的符号和查找符号的hash、名称都相同,返回索引【i】if((hash | (uint32_t)1) == (symhash | (uint32_t)1) && 0 == strcmp(symbol, symname)){*symidx = i;XH_LOG_INFO("found %s at symidx: %u (GNU_HASH DEF)\n", symbol, *symidx);return 0;}//chain ends with an element with the lowest bit set to 1if(symhash & (uint32_t)1) break;i++;}return XH_ERRNO_NOTFND;
}
.gnu.hash section 

一个GNU_HASH节由四个分离的部分组成,按顺序如下:

  • Header 32位字数组(4条目)提供节参数:
    • nbuckets:哈希桶个数
    • symndx:动态符号表有dynsymcount数量的符号。symndx是动态符号表中可被访问的第一个符号的索引,从这里起我们称为符号表第二部分。这意味着有(dynsymcount-symndx)个符号可以通过哈希表访问到。
      动态符号表中的某些符号在哈希表中永远找不到,比如共享对象: 所有UNDEF符号。
    • maskwords:在哈希表节的Bloom filter部分中,ELFCLASS单位大小的掩码个数(每个掩码可能为32位或64位)。这个值必须非零,同时必须是一个2的幂次数,见下面的描述。
      注意到值0可以被解释成哈希节中不存在Bloom filter。然而,GNU linkers不会做此处理——GNU哈希节永远至少包含1个掩码。
    • shift2:Bloom filter使用的位移计数。
  • Bloom Filter:GNU_HASH sections包含一个Bloom filter。该过滤器用于快速拒绝在对象中不存在的符号查找。对ELFCLASS32对象来说,Bloom filter的掩码是32位的,ELFCLASS64则是64位。
  • Hash Buckets:nbuckets长度的32位哈希桶数组。
  • Hash Values:32位哈希链值的数组,长度为(dynsymcount-symndx),每个符号都源自动态符号表的第二部分。

Header,哈希桶以及哈希链永远都是32位字,而Bloom filter可能是32或64位,这取决于ELF对象的类别。

 

Bloom Filter 

GNU hash sections包含一个Bloom filter。Bloom filters是概率性的,意味着可能会错检,但是不可能误检(简单来说,就是通过布隆过滤器的不一定在hash表中,但不通过的一定不在表中)。filter用来快速拒绝不在对象中的符号名,避免更多昂贵的哈希查找操作。通常地,只有一个对象在进程中拥有给定的符号。跳过对其他所有对象的哈希操作可以显著地增快符号查询。

GNU hash使用一个k=2的Bloom filter,这意味着会有两个独立的哈希函数为每个符号所使用。单一掩码设置位的计算方法应该如下:

H1 = dl_new_hash(sym);

H2 = H1 >> shift2;

第一个哈希结果右移(shift2)产生第二个哈希。

计算Bloom filter掩码N

N = ((H1 / C) % maskwords);

C表示一个掩码的尺寸,单位是位(bits), 即32位或64位

Bloom filter掩码N的位设置如下:

BITMASK = (1 << (H1 % C)) | (1 << (H2 % C));

链接编辑器设置这些位的方法:

bloom[N] |= BITMASK;

运行时链接器使用的测试如下:

(bloom[N] & BITMASK) == BITMASK;

 

Hash Buckets 

跟随Bloom filter的是哈希桶(nbuckets个),以32位为单位。数组中第N位置处的值是到动态符号表最低的索引:

(dl_new_hash(symname) % nbuckets) == N

如下图.dynsym所示,动态符号表有相同的键排序(hash % nbuckets):

N值相同的所有符号会在对应哈希链中以相同顺序排列,buckets[N]存的是链表中的第一个符号的索引,dynsym[buckets[N]]也就是哈希链中的第一个符号。

这也是代码中我们计算出查找符号所在的哈希桶,拿到起始符号索引i,  就可以递增i来遍历符号表进行比较的原因。

Hash Values 

GNU hash section的最后一部分包含(dynsymcount-symndx)个32位字,每个条目都是动态符号表的导出符号。每个字的前31位包含和符号表值一致的31位值。最后一位用做阻塞位。当符号是给定哈希链中最后一个或者两个桶的边界时其置1。

为了便于理解可以看下图

下面以libtest.so为例,根据xh_elf_gnu_hash_lookup_def()展示openTest函数查找过程:

$ llvm-objdump -M intel -j .gnu.hash -s libtest.so 

Contents of section .gnu.hash:

 028c 01000000 05000000 01000000 1a000000  ................

 029c 0808a000 05000000 4a3824d4 b787488d  ........J8$...H.

nbuckets=1, symndx=5(第一个可被访问的索引符号function),maskwords=1,shift2=26, Bloom words是0xa00808(elf是32位的,所以是4字节)。

Hash Buckets 的第一项是 0x00000005,是 .dynsym的索引。只有一个hash桶,后面跟的是.dynsym从symndx项的hash。

Symbol table '.dynsym' contains 7 entries:

   Num:    Value  Size Type    Bind   Vis       Ndx Name

     0: 00000000     0 NOTYPE  LOCAL  DEFAULT   UND 

     1: 00000000     0 FUNC    GLOBAL DEFAULT   UND __cxa_finalize@LIBC

     2: 00000000     0 FUNC    GLOBAL DEFAULT   UND __cxa_atexit@LIBC

     3: 00000000     0 NOTYPE  GLOBAL DEFAULT   UND foo

     4: 00000000     0 NOTYPE  GLOBAL DEFAULT   UND doo

     5: 00001590    70 FUNC    GLOBAL DEFAULT    12 function

     6: 000015e0    17 FUNC    GLOBAL DEFAULT    12 openTest

Contents of section .dynsym:

 01ec 00000000 00000000 00000000 00000000  ................

 01fc 01000000 00000000 00000000 12000000  ................

 020c 10000000 00000000 00000000 12000000  ................

 021c 26000000 00000000 00000000 10000000  &...............

 022c 2a000000 00000000 00000000 10000000  *...............

 023c 1d000000 90150000 46000000 12000c00  ........F.......

 024c 2e000000 e0150000 11000000 12000c00  ................

xh_elf_gnu_hash("openTest")返回hash为0x8d4887b7,计算掩码位:

 size_t mask = 0| (size_t)1 << (hash % 32)| (size_t)1 << ((hash >> shift2) % 32);

 mask = 0x800008

word = 0xa00808

(word & mask) = 0x800008 (mask)

所以可以快速判断openTest在.dynsym符号表中,继续下一步。

 uint32_t i = self->bucket[hash % self->bucket_cnt];

根据hash找到对应的桶,buckets[N]就是哈希链中动态符号表相同键排序的符号的最低的索引。

这里nbuckets=1, .dynsym第二部分任何符号都在这个桶里,最低索引是5, i = bucket[0]=0x00000005也反映了这个事实。

 从索引 i 开始递归匹配符号名称和hash值。

const char     *symname = self->strtab + self->symtab[i].st_name;
.dynsym第5项在.dynstr的偏移0x1d即第29字节处为funtion。

Contents of section .dynstr:

 02ec 005f5f63 78615f66 696e616c 697a6500  .__cxa_finalize.

 02fc 5f5f6378 615f6174 65786974 0066756e  __cxa_atexit.fun

 030c 6374696f 6e00666f 6f00646f 6f006f70  ction.foo.doo.op

 031c 656e5465 7374006c 6962632e 736f004c  enTest.libc.so.L

 032c 49424300 6c69626c 6f672e73 6f006c69  IBC.liblog.so.li

 033c 627a2e73 6f006c69 62632b2b 5f736861  bz.so.libc++_sha

 034c 7265642e 736f006c 69626d2e 736f006c  red.so.libm.so.l

 035c 6962646c 2e736f00                                    ibdl.so.

const uint32_t  symhash = self->chain[i - self->symoffset];
从哈希链索引0开始取hash

第一次比较, 符号表当前符号为function, symhash=0xd424384a,都不匹配;
i++;
第二次比较,符号表当前符号为openTest, symhash=0x8d4887b7,hash=0x8d4887b7,匹配,此时i=6。

 

GOT表替换

根据找到的符号索引对.rel(a).plt对应的got位置的数据进行替换。
其他几个.rel(a).xxx对应的套路是一模一样的,这里分析一下rel(a).plt 替换的套路:

//replace for .rel(a).plt
if(0 != self->relplt)
{xh_elf_plain_reloc_iterator_init(&plain_iter, self->relplt, self->relplt_sz, self->is_use_rela);
//第一步,遍历过程中取到.rel.plt数组的一个元素rel_commonwhile(NULL != (rel_common = xh_elf_plain_reloc_iterator_next(&plain_iter))){//找到got中符号索引相同的条目记录的重定位地址addr,并进行指针替换,addr 为so的基地址+ r_offsetif(0 != (r = xh_elf_find_and_replace_func(self,(self->is_use_rela ? ".rela.plt" : ".rel.plt"), 1,symbol, new_func, old_func,symidx, rel_common, &found))) return r;if(found) break;}
}static int xh_elf_find_and_replace_func(xh_elf_t *self, const char *section,int is_plt, const char *symbol,void *new_func, void **old_func,uint32_t symidx, void *rel_common,int *found)
{ElfW(Rela)    *rela;ElfW(Rel)     *rel;ElfW(Addr)     r_offset;size_t         r_info;size_t         r_sym;size_t         r_type;ElfW(Addr)     addr;int            r;if(NULL != found) *found = 0;//取得条目的r_info 4字节数据,对应符号索引和类型//取得条目的r_offset 4字节数据,对应got的偏移地址if(self->is_use_rela){rela = (ElfW(Rela) *)rel_common;r_info = rela->r_info;r_offset = rela->r_offset;}else{rel = (ElfW(Rel) *)rel_common;r_info = rel->r_info;r_offset = rel->r_offset;}//check sym//#define ELF32_R_SYM(x) ((x) >> 8)  r_info的高三字节是符号索引,和之前找到的符号索引比较r_sym = XH_ELF_R_SYM(r_info);if(r_sym != symidx) return 0;//check type//r_info的最低字节是重定位类型, 如果是plt,针对不同的ELFCLASS,XH_ELF_R_GENERIC_JUMP_SLOT预定义为函数类型,如i686架构是R_386_JMP_SLOT, arm是R_ARM_JUMP_SLOT等r_type = XH_ELF_R_TYPE(r_info);if(is_plt && r_type != XH_ELF_R_GENERIC_JUMP_SLOT) return 0;if(!is_plt && (r_type != XH_ELF_R_GENERIC_GLOB_DAT && r_type != XH_ELF_R_GENERIC_ABS)) return 0;//we found it
//将found标记为找到XH_LOG_INFO("found %s at %s offset: %p\n", symbol, section, (void *)r_offset);if(NULL != found) *found = 1;//do replace
//函数实际内存地址=so的基地址+ r_offset,然后进行替换addr = self->bias_addr + r_offset;if(addr < self->base_addr) return XH_ERRNO_FORMAT;if(0 != (r = xh_elf_replace_function(self, symbol, addr, new_func, old_func))){XH_LOG_ERROR("replace function failed: %s at %s\n", symbol, section);return r;}return 0;
}static int xh_elf_replace_function(xh_elf_t *self, const char *symbol, ElfW(Addr) addr, void *new_func, void **old_func)
{void         *old_addr;unsigned int  old_prot = 0;unsigned int  need_prot = PROT_READ | PROT_WRITE;int           r;//already replaced?//here we assume that we always have read permission, is this a problem?if(*(void **)addr == new_func) return 0;//get old protif(0 != (r = xh_util_get_addr_protect(addr, self->pathname, &old_prot))){XH_LOG_ERROR("get addr prot failed. ret: %d", r);return r;}if(old_prot != need_prot){//set new protif(0 != (r = xh_util_set_addr_protect(addr, need_prot))){XH_LOG_ERROR("set addr prot failed. ret: %d", r);return r;}}//save old funcold_addr = *(void **)addr;if(NULL != old_func) *old_func = old_addr;//replace func
//指令替换*(void **)addr = new_func; //segmentation fault sometimesif(old_prot != need_prot){//restore the old protif(0 != (r = xh_util_set_addr_protect(addr, old_prot))){XH_LOG_WARN("restore addr prot failed. ret: %d", r);}}//clear cache
//清除cpu 指令缓存xh_util_flush_instruction_cache(addr);XH_LOG_INFO("XH_HK_OK %p: %p -> %p %s %s\n", (void *)addr, old_addr, new_func, symbol, self->pathname);return 0;
}

同样的还是以libtest.so为例,展示一下GOT表的替换过程。

libtest.so 的.rel.plt section:

$ llvm-readelf -r libtest.so

Relocation section '.rel.plt' at offset 0x384 contains 4 entries:

 Offset     Info    Type                Sym. Value  Symbol's Name

0000272c  00000107 R_386_JUMP_SLOT        00000000   __cxa_finalize@LIBC

00002730  00000207 R_386_JUMP_SLOT        00000000   __cxa_atexit@LIBC

00002734  00000607 R_386_JUMP_SLOT        000015e0   openTest

00002738  00000407 R_386_JUMP_SLOT        00000000   doo

每个条目8字节,前面查询openTest找到符号索引是6, 遍历.rel.plt,找到第三条目匹配,Info字段高三字节索引为6,类型为需要的07(XH_ELF_R_GENERIC_JUMP_SLOT):

00002734  00000607 R_386_JUMP_SLOT        000015e0   openTest  

Offset 0x00002734就是GOT表中的位置, 打印一下.got.plt 

$ llvm-objdump -M intel -j .got.plt -s libtest.so

Contents of section .got.plt:

 2720 3c260000 00000000 00000000 f6150000  <&..............

 2730 06160000 16160000 26160000           ........&...

 地址偏移0x00002734处的值是0x1616,默认初始值是带有@plt标记的方法的第二条指令的地址,如这里的openTest@plt代码段push 0x10指令。

00001610 <openTest@plt>:

    1610: ff a3 14 00 00 00            jmp dword ptr [ebx + 0x14]

    1616: 68 10 00 00 00               push 0x10

    161b: e9 c0 ff ff ff               jmp 0x15e0 <.plt>

 我们将so的基地址+ offset的内存处替换成替换函数的地址即可完成函数的hook。

通用内存快照裁剪压缩库Tailor介绍及源码分析(二)


http://www.ppmy.cn/server/113596.html

相关文章

Linux查看系统用户

要在 Linux 系统中查询用户&#xff0c;你可以使用以下命令&#xff1a; 查看所有用户&#xff1a; cat /etc/passwd 这将列出系统上的所有用户账户及其基本信息。 查询特定用户&#xff1a; getent passwd username 替换 username 为你要查询的用户名称。 列出当前登录用户…

QT Creator在线安装包、离线包下载链接

在线安装 Download Qt OSS: Get Qt Online Installer 离线安装 Download Source Package Offline Installers | Qt

Android的Launch

看了一下资料&#xff0c;其实差别并不像一般的bootloader之类那么大。基本上还是和普通的APK程序差不多&#xff0c;基本上是AMS启动的第一个带界面的程序&#xff0c;这个界面也是常规的开发模式。可以设置各种view&#xff0c;可以设置背景。 然后在这个程序中&#xff0c;…

使用ffmpeg的c++库读取视频流和其中的SEI数据

使用ffmpeg读取视频流和其中的SEI数据&#xff08;未完待续&#xff09; FFmpeg是一个多媒体软件框架&#xff0c;支持多种新旧视频编码格式&#xff0c;提供解码、编码、 转码、多路复用、解复用、流式传输、过滤和播放等功能。其包含&#xff1a; C库libavcodec、libavutil…

es6中解构赋值

ES6&#xff08;ECMAScript 2015&#xff09;引入了一种新的表达式——解构赋值&#xff08;Destructuring assignment&#xff09;&#xff0c;允许你直接从数组或对象中提取数据&#xff0c;赋值给声明的变量。这种方式使得数据提取更加简洁明了。 数组解构赋值 数组解构赋…

iOS——frame和bounds的区别

把frame理解为占用区域&#xff0c;把bounds理解为边界。View在旋转过程中&#xff0c;其实自己的坐标系统并没有发生改变&#xff0c;bounds中的origin只能通过setBounds方法修改。 frame 定义了视图在其父视图坐标系统中的位置和大小。其坐标系是相对于俯视图的坐标系。 bou…

MySQL常用函数(总结)详细版

1. 字符串函数 CONCAT(str1, str2, ...)&#xff1a;将多个字符串连接成一个字符串。 SELECT CONCAT(Hello, , World); LENGTH(str)&#xff1a;返回字符串的长度&#xff08;字节数&#xff09;。 SELECT LENGTH(Hello); SUBSTRING(str, pos, len)&#xff1a;从字符串 …

深度学习 目标分类 目标检测 多目标跟踪 基础 进阶

深度学习 目标分类 目标检测 多目标跟踪 基础 进阶 flyfish 深度学习基础 文章名称链接深度学习基础 - 直线链接深度学习基础 - 梯度垂直于等高线的切线链接深度学习基础 - 向量投影链接一阶优化算法&#xff08;如梯度下降&#xff09;和二阶优化算法&#xff08;如牛顿法&…