Android代码注入框架adbi(续)

Home / Android MrLee 2015-12-2 2919

上篇中,我大致介绍了一下如何将一个dlopen()的调用插入到指定进程的执行序列中去。 但是,光插入这个没用,还没有具体解决如何hook进程中指定函数的问题。这个任务就要交给dlopen()函数加载进来的那个动态库来完成了。 但是具体要hook哪个进程内的,哪个动态库中的哪个函数,以及hook之后做什么,肯定是要使用者自己来指定的。adbi的作者写了一个简单的框架来帮助使用者,这个就是所谓的instruments,和前面的hijack同级目录。 有了前篇的铺垫,要分析这个instruments就简单的多了。同样的,要想hook一个函数,也要解决两个问题,一是找到这个函数在内存中的位置,二是如何修改函数偷梁换柱。 首先,我们来看看第一个问题是如何解决的。具体的代码位于base\util.c中,先从find_name()函数开始。
int find_name(pid_t pid, char *name, char *libn, unsigned long *addr)
{
	struct mm mm[1000];
	unsigned long libcaddr;
	int nmm;
	char libc[1024];
	symtab_t s;
	if (0 > load_memmap(pid, mm, &nmm)) {
		log("cannot read memory map\n")
		return -1;
	}
	if (0 > find_libname(libn, libc, sizeof(libc), &libcaddr, mm, nmm)) {
		log("cannot find lib: %s\n", libn)
		return -1;
	}
	s = load_symtab(libc);
	if (!s) {
		log("cannot read symbol table\n");
		return -1;
	}
	if (0 > lookup_func_sym(s, name, addr)) {
		log("cannot find function: %s\n", name);
		return -1;
	}
	*addr += libcaddr;
	return 0;
}

看着是不是有点眼熟?没错,这里也是从读取“/proc/<进程号>/maps”内存文件下手的,函数load_memmap()和在hijack里的实现一模一样,这里就不再详细解释了,不清楚的话可以参考上篇。 好了,读取并解析完宿主进程的内存映射信息后,接下来调用了find_libname()函数,实现如下:
static int find_libname(char *libn, 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(libn, p, strlen(libn)))
			continue;
		p += strlen(libn);
		if (!strncmp("so", p, 2) || 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] = '';
		
	mprotect((void*)m->start, m->end - m->start, PROT_READ|PROT_WRITE|PROT_EXEC);
	return 0;
}

这个函数还是非常容易懂的,逐项比对宿主进程的内存映射段,如果内存段没有名字就跳过,如果有名字则从名字字符串后面往前找第一个“/”,并将这个字符之后的子字符串和要查找的库名进行比较。如果一样的话,证明找到了,退出循环;如果不一样的话,说明没找到就继续找。
从后往前找“/”的目的,其实就是想查出加载进来库的文件名称,而不管它的具体你存放路径,因为一般库的文件名就是库名加上“.so”后缀。
举个例子,假设我们想找宿主进程中的“libc”库的内存起始位置,且宿主进程的内存映射段正好有下面这项:
400c0000-40107000 r-xp 00000000 b3:16 832        /system/lib/libc.so 

从后往前找到第一个“/”,然后截取出后面的子串,就是“libc.so”。和要查找的库名“libc”比较,刚好一致,就说明找到了。当然,仔细推敲的话,这段代码逻辑有点混乱,而且存在溢出的风险。
接下来,找到这个段后,将这个段的起始地址,还有具体的库文件存放路径用指针传递返回。
最后,也是最关键的一步。由于代码段加载进内存并链接好后,一般就不需要修改了,所以代码段通常是没有写入属性的。没有写入属性就意味着不能更改,就算找到了也白搭。这时候,就要用mprotect()函数,修改该库内存段的属性,加上可写(PROT_WRITE)属性。
好了,找到了库的具体存放路劲后,接着调用了load_symtab()函数。题外话,为什么这里的变量名用“libc”,其实在hijack中有一个find_libc()函数,估计是直接拷贝过来的没修改干净,呵呵。
static symtab_t load_symtab(char *filename)
{
	int fd;
	symtab_t symtab;
	symtab = (symtab_t) xmalloc(sizeof(*symtab));
	memset(symtab, 0, sizeof(*symtab));
	fd = open(filename, O_RDONLY);
	if (0 > fd) {
		log("%s open\n", __func__);
		return NULL;
	}
	if (0 > do_load(fd, symtab)) {
		log("Error ELF parsing %s\n", filename)
		free(symtab);
		symtab = NULL;
	}
	close(fd);
	return symtab;
}

 
该函数首先打开库文件,然后调用do_load()函数从库文件中解析出符号表。这部分代码在hijack里面其实也有,完全是一样的。那什么是符号表呢?这个要说清楚比较复杂,需要对ELF文件有比较深入的了解,这里就不再展开详细解释了,只是稍微说明一下。
符号表中包括了库文件可以导出的符号,也就是在库文件中定义了的全局变量和函数的名称;同时,还包括了要导入的符号,也就是该库要依赖别的库所提供的函数或变量的情况。
对于前者来说,处理起来相对要简单一些,每个符号的值其实就是函数的起始代码相对于库文件头位置的偏移。注意,这里是不能写函数具体映射到内存里的地址的,因为库加载到内存中的位置是不固定的。而对于后一种情况,处理起来就比较复杂了。还好这里不用涉及,因为只会修改库导出的函数。
通过解析指定库ELF文件中的“.symtab”或者“.dynsym”节,就可以获得库中所有的符号信息,do_load()函数就是做的这个工作。
符号表都解析完之后,接着就调用lookup_func_sym()函数。该函数的功能就是从符号表中,找到指定名称的符号,并返回其数值。由于是要找到库中某个导出函数的地址值,前面已经介绍过了,这里查到的符号值其实就是要找的那个函数的起始地址相对于整个库的起始地址的偏移。
通过前面调用的函数find_libname(),已经找到了库的起始地址,所以要找的函数的地址就是库的起始地址,加上函数名符号的值就可以了。好了,这就是find_name()找到指定库中指定函数起始地址的过程。
至此为止,第一个问题解决了,下面我们来看它接下来是如何具体修改函数来完成hook的目的的。具体的实现在hook()函数里面,代码位于base\hook.c中:
int hook(struct hook_t *h, int pid, char *libname, char *funcname, void *hook_arm, void *hook_thumb)
{
	unsigned long int addr;
	int i;
	if (find_name(pid, funcname, libname, &addr) < 0) {
		log("can't find: %s\n", funcname)
		return 0;
	}
	
	log("hooking:   %s = 0x%x ", funcname, addr)
	strncpy(h->name, funcname, sizeof(h->name)-1);
        ......

先是通过find_name()函数找到具体要hook函数的地址,这个在前面已经解释过了。然后将函数名字符串拷贝给传进来的hook_t结构体中的name变量中。
这个结构体是专门为hook而定义的,具体结构如下:
struct hook_t {
	unsigned int jump[3];        /* 要修改的hook指令(Arm) */
	unsigned int store[3];       /* 被修改的原指令(Arm) */
	unsigned char jumpt[20];     /* 要修改的hook指令(Thumb) */
	unsigned char storet[20];    /* 被修改的源指令(Thumb) */
	unsigned int orig;           /* 被hook的函数地址 */
	unsigned int patch;          /* hook的函数地址 */
	unsigned char thumb;         /* 表明要hook函数使用的指令集,1为Thumb,0为Arm */
	unsigned char name[128];     /* 被hook的函数名 */
	void *data;
};

在正式开始修改原指定函数之前,还有一个问题需要解决。大家知道,Arm处理器支持两种指令集,一是基本的Arm指令集,二是Thumb指令集。有可能要hook的函数是被编译成Arm指令集的,也有可能是被编译成Thumb指令集的。如果一个用Arm指令集编译的函数被你用Thumb指令集的指令修改了,那必定会崩溃,反之亦然。那如何判断要hook的函数是用那种指令集的呢?这里有一个简单的方法,就是看函数跳转地址的最后两位是不是全0,如果是的话那就一定是用Arm指令,如果两位不全为0,那一定是用Thumb指令集。代码中是这样判断的:
/* 使用Arm指令集的情况 */
if (addr % 4 == 0) {
    ......
} 
/* 使用Thumb指令集的情况 */
else {
    ......
}

 
这是因为Arm与Thumb之间的状态切换是通过专用的转移交换指令BX来实现。BX指令以通用寄存器(R0~R15)为操作数,通过拷贝Rn到PC实现绝对跳转。BX利用Rn寄存器中目的地址值的最后一位判断跳转后的状态,如果为“1”表示跳转到Thumb指令集的函数中,如果为“0”表示跳转到Arm指令集的函数中。而Arm指令集的每条指令是32位,即4个字节,也就是说Arm指令的地址肯定是4的倍数,最后两位必定为“00”。所以,直接就可以将从符号表中获得的调用地址模4,看是否为0来判断要修改的函数是用Arm指令集还是Thumb指令集。
注意,这里的调用地址与函数的映射地址还是不一样的概念。所谓调用地址,是从库函数ELF文件中的符号表中获得的。但是Thumb指令集是16位的,也就意味着不可能映射到奇数地址上,映射地址的最后一位肯定不是“1”。关于这点,这里举个例子,就拿adbi自己的instruments库中的example来说,其符号表如下:


可以看出,例如其my_epoll_wait()函数来说,其符号值是0x000013fd,最后一位是“1”,表示其是用Thumb指令集编译的。而这个函数具体在内存中的映射地址是:


可以看出,其真正的映射地址是-x000013fc,最后一位是“0”。所以,通过这个比较可以看出,编译器如果用Thumb指令集编译了一个函数,会自动将该函数的符号地址设置成真正映射地址的最后一位变成“1”,这样可以实现无缝的Thumb指令集函数与Arm指令集代码混编。
有点扯远了,下面我们分别来看代码对Arm指令集和Thumb指令集分别是如何处理的,首先来看针对Arm的处理:
log("ARM using 0x%x\n", hook_arm)
h->thumb = 0;
h->patch = (unsigned int)hook_arm;
h->orig = addr;
h->jump[0] = 0xe59ff000; // LDR pc, [pc, #0]
h->jump[1] = h->patch;
h->jump[2] = h->patch;
for (i = 0; i < 3; i++)
        h->store[i] = ((int*)h->orig)[i];
for (i = 0; i < 3; i++)
        ((int*)h->orig)[i] = h->jump[i];

对Arm的处理还是非常简单的,先将hook函数和要被hook函数的地址保留下来。然后生成hook的代码,只有3个4字节就是12个字节,第一个字节是代码“LDR pc, [pc, #0]”,由于pc寄存器读出的值实际上是当前指令地址加8,所以这里是把jump[2]的值加载进pc寄存器中,而jump[2]处保存的是hook函数的地址。因此,jump[0~3]实际上保存的是跳转到hook函数的指令。再下面,将被hook函数的前3个4自己保存下来,方便以后恢复。最后,将跳转指令写到被hook函数的前12字节。这样,当要调用被hook函数的时候,实际执行的指令就是跳转到hook函数。是不是很简单?对Thumb指令的处理其实也非常相似:
if ((unsigned long int)hook_thumb % 4 == 0)
	log("warning hook is not thumb 0x%x\n", hook_thumb)
h->thumb = 1;
log("THUMB using 0x%x\n", hook_thumb)
h->patch = (unsigned int)hook_thumb;
h->orig = addr;	
h->jumpt[1] = 0xb4;
h->jumpt[0] = 0x30; // push {r4,r5}
h->jumpt[3] = 0xa5;
h->jumpt[2] = 0x03; // add r5, pc, #12
h->jumpt[5] = 0x68;
h->jumpt[4] = 0x2d; // ldr r5, [r5]
h->jumpt[7] = 0xb0;
h->jumpt[6] = 0x02; // add sp,sp,#8
h->jumpt[9] = 0xb4;
h->jumpt[8] = 0x20; // push {r5}
h->jumpt[11] = 0xb0;
h->jumpt[10] = 0x81; // sub sp,sp,#4
h->jumpt[13] = 0xbd;
h->jumpt[12] = 0x20; // pop {r5, pc}
h->jumpt[15] = 0x46;
h->jumpt[14] = 0xaf; // mov pc, r5 ; just to pad to 4 byte boundary
memcpy(&h->jumpt[16], (unsigned char*)&h->patch, sizeof(unsigned int));
unsigned int orig = addr - 1; // sub 1 to get real address
for (i = 0; i < 20; i++) {
	h->storet[i] = ((unsigned char*)orig)[i];
}
for (i = 0; i < 20; i++) {
	((unsigned char*)orig)[i] = h->jumpt[i];
}

可以看到,和对Arm指令集的处理非常相似,只不过跳转指令换成了Thumb。和Arm的处理不同,这里是通过pop指令来修改PC寄存器的,设计的还是比较精巧的。
首先,压栈r4和r5寄存器,将r5压栈是因为后面的程序修改了r5寄存器,压栈后方便以后恢复,而将r4寄存器压栈纯粹是为了要保留一个位置。
接着,将PC寄存器的值加上12赋值给r5。加上的立即数必须是4的倍数,而加上8又不够,只能加12。这样的话,读出的PC寄存器的值是当前指令地址加上4,再加上12的话,那么可以算出来r5寄存器的值实际指向的是jumpt[18],而不是jumpt[16]了。
这里还有一点需要注意,对于Thumb的“Add Rd, Rp, #expr”指令来说,如果Rp是PC寄存器的话,那么PC寄存器读出的值应该是(当前指令地址+4)& 0xFFFFFFFC,也就是去掉最后两位,算下来正好可以减去2。但这里也有个假设,就是被hook函数的起始地址必须是4字节对齐的,哪怕被hook函数使用Thumb指令集写的。
再下面的指令的目的就是将保存在jumpt[16]处的hook函数地址加载到r5寄存器中。
后面就是一些栈操作了,大概的流程画了个图如下:


所以,下面的“pop {r5, pc}”指令刚好可以完成恢复r5寄存器并且修改PC寄存器,从而跳转到hook函数的操作。
接下来的指令(从jumpt[14])完全是多余的了,完全不会执行到,只是因为前面的add指令只能加4的倍数。最后,还有一点不同的是,因为被hook函数是Thumb指令集,所以其真正的内存映射地址是其符号地址减去1。
好了,经过上面的处理,被hook函数的前几条指令已经被修改成跳转到hook函数的指令了,如果接下来被hook的函数被调用到了,实际上就会跳转到指定的hook函数上去。
不过,这里还有一个问题没有解决,那就是现代的处理器都有指令缓存,用来提高执行效率,虽然前面的操作修改了内存中的指令,但有可能被修改的指令之前已经被缓存起来了,再执行的时候还是优先执行缓存中的指令,使得修改的指令得不到执行。关于这个问题,解决的方法是刷新缓存。实际的做法是触发一个影藏的系统调用,具体实现如下:
void inline hook_cacheflush(unsigned int begin, unsigned int end)
{	
	const int syscall = 0xf0002;
	__asm __volatile (
		"mov	 r0, %0\n"			
		"mov	 r1, %1\n"
		"mov	 r7, %2\n"
		"mov     r2, #0x0\n"
		"svc     0x00000000\n"
		:
		:	"r" (begin), "r" (end), "r" (syscall)
		:	"r0", "r1", "r2", "r7"
		);
}

这里用到了C里面内嵌GCC汇编指令,详细的语法解释可以参考《如何在C或C++代码中嵌入ARM汇编代码》。在这里,用到了编号为0xf0002的系统调用,这个系统调用是私有的:
#define __ARM_NR_BASE (__NR_SYSCALL_BASE+0x0f0000) 
#define __ARM_NR_breakpoint (__ARM_NR_BASE+1) 
#define __ARM_NR_cacheflush (__ARM_NR_BASE+2) 
#define __ARM_NR_usr26 (__ARM_NR_BASE+3) 
#define __ARM_NR_usr32 (__ARM_NR_BASE+4) 
#define __ARM_NR_set_tls (__ARM_NR_BASE+5)

 
可以看到0xf0002刚好对应到定义为“__ARM_NR_cacheflush”的系统调用。这个系统调用接受三个参数,分别用寄存器r0、r1和r2传递进来。第一个参数(r0)表示要刷缓存的指令的起始地址;第二个参数(r1)表示指令的结束地址;第三个参数必须为0。“svc”指令用来实现Arm下的软终端,从而触发系统调用,而具体的系统调用号保存在寄存器r7中。
在hook()函数的末尾,对这个函数进行了调用:
        hook_cacheflush((unsigned int)h->orig, (unsigned int)h->orig+sizeof(h->jumpt));

 
刷新的初始值设置为被hook函数的起始地址,末尾设置成了起始地址加上jumpt的长度,也就是20个字节。为什么Arm指令集也这么设置?因为Arm的处理只需要修改12个字节,而Thumb需要修改20个字节,所以修改个长的总没错。
那么,这个hook()函数由谁调用呢?实际上是在这个“.so”文件被dlopen()加载进目标进程中就会被调用,代码位于example\epoll.c中:
void __attribute__ ((constructor)) my_init(void);
......
void my_init(void)
{
	counter = 3;
	log("%s started\n", __FILE__)
 
	set_logfunction(my_log);
	hook(&eph, getpid(), "libc.", "epoll_wait", my_epoll_wait_arm, my_epoll_wait);
}

最开始的那条语句是告诉编译器,编译出来的“.so"动态库的初始函数是my_init(),而在my_init()函数中调用了hook()函数,完成了跳转指令的修改。
到此为止,已经完成了“偷梁换柱”的工作,对被hook函数的调用,实际上已经被跳转到了hook函数中。但是,有时候当hook函数执行完后,还希望返回原始被hook的函数继续执行,这要怎么做呢?在instruments自带的示例代码,位于example\epoll.c中有这种做法:
int my_epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
{
	int (*orig_epoll_wait)(int epfd, struct epoll_event *events, int maxevents, int timeout);
	orig_epoll_wait = (void*)eph.orig;
	hook_precall(&eph);
	int res = orig_epoll_wait(epfd, events, maxevents, timeout);
	if (counter) {
		hook_postcall(&eph);
		log("epoll_wait() called\n");
		counter--;
		if (!counter)
			log("removing hook for epoll_wait()\n");
	}
        
	return res;
}

my_epoll_wait()函数就是hook函数,可以看到,在调用原始的被hook函数之前调用了hook_precall()函数,而调用之后又调用了hook_postcall()函数,它们的实现代码位于base\hook.c中,如下:
void hook_precall(struct hook_t *h)
{
	int i;
	
	if (h->thumb) {
		unsigned int orig = h->orig - 1;
		for (i = 0; i < 20; i++) {
			((unsigned char*)orig)[i] = h->storet[i];
		}
	}
	else {
		for (i = 0; i < 3; i++)
			((int*)h->orig)[i] = h->store[i];
	}	
	hook_cacheflush((unsigned int)h->orig, (unsigned int)h->orig+sizeof(h->jumpt));
}
void hook_postcall(struct hook_t *h)
{
	int i;
	
	if (h->thumb) {
		unsigned int orig = h->orig - 1;
		for (i = 0; i < 20; i++)
			((unsigned char*)orig)[i] = h->jumpt[i];
	}
	else {
		for (i = 0; i < 3; i++)
			((int*)h->orig)[i] = h->jump[i];
	}
	hook_cacheflush((unsigned int)h->orig, (unsigned int)h->orig+sizeof(h->jumpt));	
}

非常的简单,hook_precall()函数就是把前面hook()函数中保存在storet或者store中的被hook函数的原始指令写回去。这样接下来再调用原始函数的话就能完成其原有的功能。而hook_postcall()刚好相反,把保存在jumpt或者jump中的跳转指令写到被hook函数开头,从而实现hook的动作。这之后再调用被hook函数,就会跳转到hook函数中去。
好了,到此在instruments中所有的逻辑都交代完了,实现原理还是非常简单的。
最后,再聊聊如何控制将代码编译成Arm指令集或者是Thumb指令集的问题。
Android NDK默认情况下将C代码编译成Thumb指令,如果想将C代码编译成Arm指令集,有两种方法:
  1. 在Android.mk文件中添加上“LOCAL_ARM_MODE := arm”,这样会默认将所有的C代码编译成Arm指令集。
  2. 前面的方法只能将所有代码全部编译成Arm指令集,如果想一部分代码编译成Arm,一部分编译成Thumb就力不从心了。想要达到这个目的,可以将那些你想编译成Arm指令集的C代码文件名字后面加上一个“.arm”后缀。而其它的没有加上“.arm”后缀的C文件将使用“LOCAL_ARM_MODE”指定的指令集编译,默认情况下是Thumb。注意,这里只是在“LOCAL_SRC_FILES”里列出的C文件名后加上“.arm”后缀就可以了,不要真的去改那个要编译的C文件名。
base目录下的所有代码是通过第一种方法指定编译成Arm指令集的,并且是编译成静态库:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE    := base
LOCAL_SRC_FILES := ../util.c ../hook.c ../base.c
LOCAL_ARM_MODE := arm
include $(BUILD_STATIC_LIBRARY)

而example目录下的实例是用第二种方法指定“epoll.c”编译成Thumb指令,而“epoll_arm.c”编译成Arm指令集,同时连接通过base编译出的静态库:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE    := libexample
LOCAL_SRC_FILES := ../epoll.c  ../epoll_arm.c.arm
LOCAL_LDLIBS    := -L./libs -ldl -ldvm -lbase
LOCAL_LDLIBS := -Wl,--start-group ../../base/obj/local/armeabi/libbase.a  -Wl,--end-group
LOCAL_CFLAGS := -g
include $(BUILD_SHARED_LIBRARY)

 

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

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