Android代码注入框架adbi

Home / Android MrLee 2015-12-2 3746

要想真正做到随意hook一个进程中的指定函数,还是有很多工作要做。而adbi(The Android Dynamic Binary Instrumentation Toolkit)就是一个通用的框架,使得hook变得异常简单。可以从这里获得其源代码:adbi-master
它的基本实现原理是利用ptrace()函数attach到一个进程上,然后在其调用序列中插入一个调用dlopen()函数的步骤,将一个实现预备好的.so文件加载到要hook的进程中,最终由这个加载的.so文件在初始化函数中hook指定的函数。
整个adbi工具集由两个主要模块组成,分别是用于.so文件注入的进程劫持工具(hijack tool)和一个修改函数入口的基础库。接下来,我们还是通过阅读代码来分别了解这两个模块的实现原理,本篇我们先将重点放在劫持工具的实现上。
前面的介绍中已经提到过了,这个模块的作用是将一个调动dlopen()函数的步骤插入到指定进程的调用序列中。
要做到这点,需要解决几个问题,一是如何获得目标进程中dlopen()函数的调用地址,二是如何插入一个调用dlopen()函数的步骤。
好,我们先来看看第一个问题是如何解决的。在adbi中,具体是通过以下几行代码来找到目标进程中dlopen()函数的起始地址的:
void *ldl = dlopen("libdl.so", RTLD_LAZY);
if (ldl) {
    dlopenaddr = dlsym(ldl, "dlopen");
    dlclose(ldl);
}
unsigned long int lkaddr;
unsigned long int lkaddr2;
find_linker(getpid(), &lkaddr);
find_linker(pid, &lkaddr2);
dlopenaddr = lkaddr2 + (dlopenaddr - lkaddr);

首先用dlopen()在当前进程中加载libdl.so动态库,接着用dlsym()函数获得dlopen()函数的调用地址。感觉有点先有鸡还是先有蛋的问题了,这里稍微说明一下,libdl.so库肯定早就已经加载到进程中了,这里再加载一次其实并不会真的把这个动态库再在内存中的另一个位置加载一次,而是返回已经加载过的地址。
但是,获得了dlopen()函数在当前进程中的地址后并没有用,因为libdl.so是动态库,每次会被动态的加载到任意的一个地址上去,并不会固定。所以,当前进程中dlopen()函数的地址,一般情况下并不等于别的进程中dlopen()函数的地址。那要如何才能获得指定进程中的dlopen()函数的地址呢?代码中调用到了find_linker()函数,我们接下来看看它的实现:
static int find_linker(pid_t pid, unsigned long *addr)
{
    struct mm mm[1000];
    unsigned long libcaddr;
    int nmm;
    char libc[256];
    symtab_t s;
    if (0 > load_memmap(pid, mm, &nmm)) {
        printf("cannot read memory map\n");
        return -1;
    }
    if (0 > find_linker_mem(libc, sizeof(libc), &libcaddr, mm, nmm)) {
        printf("cannot find libc\n");
        return -1;
    }
	
    *addr = libcaddr;
	
    return 1;
}

在这个函数里,主要又调用了两个函数,一个是load_memmap(),另一个是find_linker_mem()。下面我们先来分析load_memmap()函数,这个函数主要由三个步骤组成:
static int
load_memmap(pid_t pid, struct mm *mm, int *nmmp)
{
    char raw[80000]; // this depends on the number of libraries an executable uses
    char name[MAX_NAME_LEN];
    char *p;
    unsigned long start, end;
    struct mm *m;
    int nmm = 0;
    int fd, rv;
    int i;
    sprintf(raw, "/proc/%d/maps", pid);
    fd = open(raw, O_RDONLY);
    if (0 > fd) {
        printf("Can't open %s for reading\n", raw);
        return -1;
    }
    ......

首先,会打开一个内存文件,获得其句柄。该文件路径是“/proc/<进程号>/maps”,作用就是读出指定进程的内存映射信息,其格式大概如下:
40096000-40098000 r-xp 00000000 b3:16 109        /system/bin/app_process
40098000-40099000 r--p 00001000 b3:16 109        /system/bin/app_process
40099000-4009a000 rw-p 00000000 00:00 0 
4009a000-400a9000 r-xp 00000000 b3:16 176        /system/bin/linker
400a9000-400aa000 r--p 0000e000 b3:16 176        /system/bin/linker
400aa000-400ab000 rw-p 0000f000 b3:16 176        /system/bin/linker
400ab000-400ae000 rw-p 00000000 00:00 0 
400ae000-400b0000 r--p 00000000 00:00 0 
400b0000-400b9000 r-xp 00000000 b3:16 855        /system/lib/libcutils.so
400b9000-400ba000 r--p 00008000 b3:16 855        /system/lib/libcutils.so
400ba000-400bb000 rw-p 00009000 b3:16 855        /system/lib/libcutils.so
400bb000-400be000 r-xp 00000000 b3:16 955        /system/lib/liblog.so
400be000-400bf000 r--p 00002000 b3:16 955        /system/lib/liblog.so
400bf000-400c0000 rw-p 00003000 b3:16 955        /system/lib/liblog.so
400c0000-40107000 r-xp 00000000 b3:16 832        /system/lib/libc.so
40107000-40108000 ---p 00000000 00:00 0 

好了,打开句柄之后,那接下来就是一行一行读取内存映射信息文件的内容:
    ......
    /* Zero to ensure data is null terminated */
    memset(raw, 0, sizeof(raw));
    p = raw;
    while (1) {
        rv = read(fd, p, sizeof(raw)-(p-raw));
        if (0 > rv) {
            return -1;
        }
        if (0 == rv)
            break;
        p += rv;
        if (p-raw >= sizeof(raw)) {
            printf("Too many memory mapping\n");
            return -1;
        }
    }
    close(fd);
    ......

读完之后,再下来就是一行一行的解释:
    ......
    p = strtok(raw, "\n");
    m = mm;
    while (p) {
        /* parse current map line */
        rv = sscanf(p, "%08lx-%08lx %*s %*s %*s %*s %s\n",
		    &start, &end, name);
        p = strtok(NULL, "\n");
        if (rv == 2) {
            m = &mm[nmm++];
            m->start = start;
            m->end = end;
            strcpy(m->name, MEMORY_ONLY);
            continue;
        }
        if (strstr(name, "stack") != 0) {
            stack_start = start;
            stack_end = end;
        }
        /* search backward for other mapping with same name */
        for (i = nmm-1; i >= 0; i--) {
            m = &mm[i];
            if (!strcmp(m->name, name))
                break;
        }
        if (i >= 0) {
            if (start < m->start)
                m->start = start;
            if (end > m->end)
                m->end = end;
        } else {
            /* new entry */
            m = &mm[nmm++];
            m->start = start;
            m->end = end;
            strcpy(m->name, name);
	}
    }
    *nmmp = nmm;
    return 0;
}

先是按照格式解析出每行的起始地址,结束地址,和名称。如果没有解析出名称,就会用一个自定义的名称补上(“[memory]”)
#define MEMORY_ONLY  "[memory]"

 
如果名字是“stack”,表明这段内存用于栈。从前面的格式中可以看出,会有几行都叫一个名字的情况,接下来的代码会将这些连续的并且名字相同的内存段合并一下。
好了,现在就已经得到了指定进程的内存映射情况,包括起始地址、结束地址和名称。
接下来,我们继续看find_linker_mem()函数做了些什么:
static int
find_linker_mem(char *name, int len, unsigned long *start,
	  struct mm *mm, int nmm)
{
    int i;
    struct mm *m;
    char *p;
    for (i = 0, m = mm; i < nmm; i++, m++) {
        if (!strcmp(m->name, MEMORY_ONLY))
            continue;
        p = strrchr(m->name, '/');
        if (!p)
            continue;
        p++;
        if (strncmp("linker", p, 6))
            continue;
	break;
    }
    if (i >= nmm)
	/* not found */
        return -1;
    *start = m->start;
    strncpy(name, m->name, len);
    if (strlen(m->name) >= len)
        name[len-1] = '';
    return 0;
}

这个函数的作用是在前面读取的指定进程内存映射中,找出名字最后以“linker”结尾的那段内存的起始地址。其实,就是找到“/system/bin/linker”加载到内存中的地址。
顺便提一句,linker是Android提供的动态链接器,不同于普通的Linux。dlopen()函数就是在linker里面定义的(bionic/linker/dlfcn.cpp):
static Elf32_Sym gLibDlSymtab[] = {
  // Total length of libdl_info.strtab, including trailing 0.
  // This is actually the STH_UNDEF entry. Technically, it's
  // supposed to have st_name == 0, but instead, it points to an index
  // in the strtab with a  to make iterating through the symtab easier.
  ELF32_SYM_INITIALIZER(sizeof(ANDROID_LIBDL_STRTAB) - 1, NULL, 0),
  ELF32_SYM_INITIALIZER( 0, &dlopen, 1),
  ELF32_SYM_INITIALIZER( 7, &dlclose, 1),
  ELF32_SYM_INITIALIZER(15, &dlsym, 1),
  ELF32_SYM_INITIALIZER(21, &dlerror, 1),
  ELF32_SYM_INITIALIZER(29, &dladdr, 1),
  ELF32_SYM_INITIALIZER(36, &android_update_LD_LIBRARY_PATH, 1),
#if defined(ANDROID_ARM_LINKER)
  ELF32_SYM_INITIALIZER(67, &dl_unwind_find_exidx, 1),
#elif defined(ANDROID_X86_LINKER) || defined(ANDROID_MIPS_LINKER)
  ELF32_SYM_INITIALIZER(67, &dl_iterate_phdr, 1),
#endif
};
// This is used by the dynamic linker. Every process gets these symbols for free.
soinfo libdl_info = {
    "libdl.so",
    phdr: 0, phnum: 0,
    entry: 0, base: 0, size: 0,
    unused1: 0, dynamic: 0, unused2: 0, unused3: 0,
    next: 0,
    flags: FLAG_LINKED,
    strtab: ANDROID_LIBDL_STRTAB,
    symtab: gLibDlSymtab,
    ........
}

关于linker目前只要知道这些就够了。所以,分析到这里可以看出,find_linker()函数的真正目的是获得“/system/bin/linker”程序加载到内存中的起始地址。
因为要注入的进程也在本机上运行,肯定用的是同一个linker,所以其内部的dlopen()函数和linker头的偏移量是固定的,这样计算其它进程内dlopen()函数的地址就非常简单了。先在本进程内,计算出dlopen()相对于linker头的偏移量,再加上要注入进程的linker头地址就好了。事实上,adbi也是这么做的,大家可以回过头去分析一下本节开头的那段代码。
好了,获得了目标进程中dlopen()函数地址后,接下来就要解决第二个问题了,即如何将调用dlopen()函数的步骤插入到目标进程的中去。要做到这步,当然肯定是要请ptrace()出马了:
// Attach 
if (0 > ptrace(PTRACE_ATTACH, pid, 0, 0)) {
    printf("cannot attach to %d, error!\n", pid);
    exit(1);
}
waitpid(pid, NULL, 0);

调用ptrace()函数,并且第一个参数说明是通过附着的方式捕获,第二个参数是要捕获的那个目标进程。ptrace函数调用成功后,被捕获的进程将成为当前进程的子进程,并且会暂停执行。下面的waitpid()函数会等待被捕获的进程,当其返回时,表示目标进程已经暂停运行了,接下来就要正式下“刀子”了。正式下手之前,当然还是要知道被捕获进程的当前状态,这还是可以通过ptrace()函数来完成:
ptrace(PTRACE_GETREGS, pid, 0, ®s);

此时,传递的第一个参数说明此次调用是想获得目标进程的所有寄存器的值。获得这些寄存器值的目的是为了将它们保存下来,然后修改它们,使得在当前正常程序的调用序列中加入一个队dlopen()函数的调用,调用完成后再回到原来的程序处继续执行。具体的奥秘在一个叫做“sc”的int型数组中:
unsigned int sc[] = {
    0xe59f0040, //        ldr     r0, [pc, #64]   ; 48 <.text+0x48>
    0xe3a01000, //        mov     r1, #0  ; 0x0
    0xe1a0e00f, //        mov     lr, pc
    0xe59ff038, //        ldr     pc, [pc, #56]   ; 4c <.text+0x4c>
    0xe59fd02c, //        ldr     sp, [pc, #44]   ; 44 <.text+0x44>
    0xe59f0010, //        ldr     r0, [pc, #20]   ; 30 <.text+0x30>
    0xe59f1010, //        ldr     r1, [pc, #20]   ; 34 <.text+0x34>
    0xe59f2010, //        ldr     r2, [pc, #20]   ; 38 <.text+0x38>
    0xe59f3010, //        ldr     r3, [pc, #20]   ; 3c <.text+0x3c>
    0xe59fe010, //        ldr     lr, [pc, #20]   ; 40 <.text+0x40>
    0xe59ff010, //        ldr     pc, [pc, #20]   ; 44 <.text+0x44>
    0xe1a00000, //        nop                     r0
    0xe1a00000, //        nop                     r1 
    0xe1a00000, //        nop                     r2 
    0xe1a00000, //        nop                     r3 
    0xe1a00000, //        nop                     lr 
    0xe1a00000, //        nop                     pc
    0xe1a00000, //        nop                     sp
    0xe1a00000, //        nop                     addr of libname
    0xe1a00000, //        nop                     dlopenaddr
};

初始化时,数组的后面都被设置成空指令,在获得了目标进程的寄存器值后,会对它们重新赋值:
sc[11] = regs.ARM_r0;
sc[12] = regs.ARM_r1;
sc[13] = regs.ARM_r2;
sc[14] = regs.ARM_r3;
sc[15] = regs.ARM_lr;
sc[16] = regs.ARM_pc;
sc[17] = regs.ARM_sp;
sc[19] = dlopenaddr;

是不是觉得漏了一个?不急,下面会对其赋值:
// push library name to stack
libaddr = regs.ARM_sp - n*4 - sizeof(sc);
sc[18] = libaddr;

变量“n”的值是:
case 'l':
    n = strlen(optarg)+1;
    n = n/4 + (n%4 ? 1 : 0);
    ......
    break;

参数-l之后跟的是要dlopen()函数加载进来的.so动态库的路径名。由于ptrace()写入目标进程是以4字节为单位的,所以这里要除4,如果不是4的倍数,在后面还要补上一个4字节。看了上面的代码是不是觉得有点晕?没关系,下面我们画个图来重点分析一下这块。


大家知道,对于栈来说,是从高内存向低内存扩展的,但是程序的执行以及数据的读取正好相反,是从低内存到高内存的。
还有一点需要说明一下,对于ARM处理器来说,pc寄存器的值,指向的不是当前正在执行指令的地址,而是往下第二条指令的地址。
好,我们正式开始分析代码的含义,指令将从codeaddr指示的位置从低到高依次执行。
第一条指令将pc寄存器的值加上64,读出那个地方的内容(4个字节),然后放到寄存器r0中。刚才说过了,pc寄存器值指向的是当前指令位置加8个字节,也就是说这条指令实际读出的是当前指令位置向下72个字节。由于sc数组是int型的,就是数组当前元素位置向下18个元素处。数一数,刚好是libaddr的位置。所以这条指令是为了让r0寄存器指向.so共享库路径名字符串。
第二条指令很简单,是将0赋值给寄存器r1。
第三条指令用来将pc寄存器值保存到lr寄存器中,这样做的目的是为了调用dlopen()函数返回后,跳转到指令“ldr sp, [pc, #56]”处。
第四条指令是将pc加上56处的数值加载到pc中,pc+56处是哪?当前指令位置往下64字节,16个元素,刚好是dlopen()函数的调用地址。所以,这条指令其实就是调用dlopen()函数,传入的参数一个是r0寄存器指向的共享库路径名,另一个是r1寄存器中的0。
调用dlopen()返回后将继续执行下面的所有指令,我就不一一分析了,作用就是恢复目标进程原来寄存器的值。先是sp,然后是r0、r1、r2、r3和lr,最后恢复原来pc的值,继续执行被暂停之前的指令,就像什么都没发生过一样。
好了,这部分就是hijack的全部核心思路,看似很神秘,其实很简单,是吧?当然,个人觉得要达到这个目的还是有很多种其它方法的,但ptrace()是必不可少的。
接下来,顺理成章,就是要把精心构造的数据写到目标进程的栈上:
// write library name to stack
if (0 > write_mem(pid, (unsigned long*)arg, n, libaddr)) {
    printf("cannot write library name (%s) to stack, error!\n", arg);
    exit(1);
}
	
// write code to stack
codeaddr = regs.ARM_sp - sizeof(sc);
if (0 > write_mem(pid, (unsigned long*)&sc, sizeof(sc)/sizeof(long), codeaddr)) {
    printf("cannot write code, error!\n");
    exit(1);
}

这个相对简单,直接从刚才读到的寄存器值中找出sp寄存器的值,这个值直接指示出当前栈顶的位置,接着往下写就好了。注意,栈是从高地址到低地址拓展的,所以计算地址时是减而不是加。write_mem()函数负责用ptrace()将一段内存值写到目标进程的指定位置:
/* Write NLONG 4 byte words from BUF into PID starting
   at address POS.  Calling process must be attached to PID. */
static int
write_mem(pid_t pid, unsigned long *buf, int nlong, unsigned long pos)
{
    unsigned long *p;
    int i;
    for (p = buf, i = 0; i < nlong; p++, i++)
        if (0 > ptrace(PTRACE_POKETEXT, pid, pos+(i*4), *p))
            return -1;
    return 0;
}

好了,有了可执行的代码在目标进程的栈上了,是不是就可以直接运行了呢?问题还没那么简单。我们来看看栈所在内存段的属性:
be846000-be867000 rw-p 00000000 00:00 0          [stack]

看到没有,栈并没有执行的属性,如果直接强行将pc指向栈所在内存段的话,有可能直接导致目标进程异常退出。
那接下来怎么办呢?能不能改一下栈内存段的属性,给其加上执行属性呢?mprotect()函数可以做到这点。
这里有同样一个问题,如何得到目标进程中mprotect()函数的调用地址呢?能不能用前面使用的在linker中找dlopen()函数调用地址一样的方法呢?笔者试过,其实是可以的,但是adbi采用了另一种方法,思路是通过直接分析ELF文件libc.so来获得其中符号mprotect的值,这个值其实就是表示mprotect()函数相对于文件头的偏移,再将这个值通libc.so文件在内存中映射的起始地址相加,就是mprotect()函数真正的调用地址了,具体代码如下:
static int
find_name(pid_t pid, char *name, unsigned long *addr)
{
    struct mm mm[1000];
    unsigned long libcaddr;
    int nmm;
    char libc[256];
    symtab_t s;
    if (0 > load_memmap(pid, mm, &nmm)) {
        printf("cannot read memory map\n");
        return -1;
    }
    if (0 > find_libc(libc, sizeof(libc), &libcaddr, mm, nmm)) {
        printf("cannot find libc\n");
        return -1;
    }
    s = load_symtab(libc);
    if (!s) {
        printf("cannot read symbol table\n");
        return -1;
    }
    if (0 > lookup_func_sym(s, name, addr)) {
        printf("cannot find %s\n", name);
        return -1;
    }
    *addr += libcaddr;
    return 0;
}

还是通过调用前面介绍的load_memmap()函数来获得目标进程的内存映射表,然后调用find_libc()函数从中找出名字末尾是“libc.so”的内存段,获得这个内存段的起始地址以及具体路径:
/* Find libc in MM, storing no more than LEN-1 chars of
   its name in NAME and set START to its starting
   address.  If libc cannot be found return -1 and
   leave NAME and START untouched.  Otherwise return 0
   and null-terminated NAME. */
static int
find_libc(char *name, int len, unsigned long *start,
	  struct mm *mm, int nmm)
{
    int i;
    struct mm *m;
    char *p;
    for (i = 0, m = mm; i < nmm; i++, m++) {
        if (!strcmp(m->name, MEMORY_ONLY))
            continue;
        p = strrchr(m->name, '/');
        if (!p)
            continue;
        p++;
        if (strncmp("libc", p, 4))
            continue;
        p += 4;
        /* here comes our crude test -> 'libc.so' or 'libc-[0-9]' */
        if (!strncmp(".so", p, 3) || (p[0] == '-' && isdigit(p[1])))
            break;
    }
    if (i >= nmm)
        /* not found */
        return -1;
    *start = m->start;
    strncpy(name, m->name, len);
    if (strlen(m->name) >= len)
        name[len-1] = '';
    return 0;
}

再下来调用load_symtab()函数,读取包含libc.so的ELF文件,分析并加载器符号表。最后,调用lookup_func_sym()函数,在符号表中查找指定名字的符号值。这部分代码牵涉到ELF文件的格式,就不重点分析了,如果有兴趣可以自己看。为什么不采用原来的方法找目标进程中mprotect()函数的调用地址呢?难道不可行吗?笔者写了如下一段函数:
static int
find_name2(pid_t pid, char *name, unsigned long *addr)
{
    struct mm mm[1000];
    unsigned long libcaddr1, libcaddr2;
    int nmm;
    char libc[256];
    symtab_t s;
    if (0 > load_memmap(pid, mm, &nmm)) {
        printf("cannot read memory map\n");
        return -1;
    }
	
    if (0 > find_libc(libc, sizeof(libc), &libcaddr1, mm, nmm)) {
        printf("cannot find libc\n");
        return -1;
    }
	
    if (0 > load_memmap(getpid(), mm, &nmm)) {
        printf("cannot read memory map\n");
        return -1;
    }
	
    if (0 > find_libc(libc, sizeof(libc), &libcaddr2, mm, nmm)) {
        printf("cannot find libc\n");
        return -1;
    }
	
    unsigned long mfunaddr;
    void *ldl = dlopen("libc.so", RTLD_LAZY);
    if (ldl) {
        mfunaddr = dlsym(ldl, name);
        dlclose(ldl);
    }
	
    *addr = mfunaddr - libcaddr2 + libcaddr1;
    return 0;
}

发现同样可以准确获得目标进程libc.so中指定函数的地址。这样也好,作者为我们展示了两种方法,给大家开开眼。
下面就简单了,修改目标进程的寄存器值,使其调用mprotect()函数,并且从mprotect()返回后直接调用栈上的代码就好了:
// calc stack pointer
regs.ARM_sp = regs.ARM_sp - n*4 - sizeof(sc);
// call mprotect() to make stack executable
regs.ARM_r0 = stack_start; // want to make stack executable
regs.ARM_r1 = stack_end - stack_start; // stack size
regs.ARM_r2 = PROT_READ|PROT_WRITE|PROT_EXEC; // protections
regs.ARM_lr = codeaddr; // points to loading and fixing code
regs.ARM_pc = mprotectaddr; // execute mprotect()

当然,栈寄存器要重新设置一下,指向库路径名的下面,免得在调用mprotect()函数的时候把前面精心写入的代码给冲掉。
r0寄存器设置为栈的起始地址,r1寄存器设置为栈的长度,r2保存要设置的属性(可读、可写且可执行)。
lr寄存器设置为栈上代码的起始地址,这样当调用mprotect()函数返回后就可以正常执行栈上代码了。
最后,将pc寄存器的值设置为前面查找到的目标进程中mprotect()函数的调用地址。
好了,万事具备,只欠东风,最后将寄存器值写到目标进程中,并且继续执行吧:
ptrace(PTRACE_SETREGS, pid, 0, ®s);
ptrace(PTRACE_DETACH, pid, 0, SIGCONT);

到目前为止,万里长征已经走完了第一步,即可以让目标集成调用dlopen()函数,自动加载一个任意指定的.so动态库。
具体如何hook,hook哪个函数,就要看.so怎么做了

本文链接:https://www.it72.com/7092.htm

推荐阅读
最新回复 (0)
返回