虽然之前注入过android很多次,但所做的事情不过是在别人的框架下做些修改,调调bug,完全没有彻底消化和掌握注入的知识和技术。所以我决定写这一篇博文,总结android注入的实现要点。
android设备存在32位和64位之分(这里专指ARM,而非x86),两者ABI的差异导致它们的一些数据结构,函数的实现存在差异。下面的注入代码可能牵扯到这些不同。这里推荐两种方法来查看这些差异。一是直接下载kernel源码,例如如果要查看ptrace.h的不同实现,就可以在kernel目录下找到32位和64位的ptrach头文件。
二是通过android studio,在gradle中可以找到如下语句。
externalNativeBuild {
ndk{
abiFilters "armeabi-v7a","arm64-v8a"
}
}
如果要看32位的头文件,就保留上面的"armeabi-v7a",否则则保留"arm64-v8a"。之后在源文件中#include<asm/ptrace.h>,然后go to declaration,就可以分别看到32位和64位的头文件了。
另外,关于arm调用约定的内容可去arm developer搜索call standard查看。
注入原理
代码注入的过程其实就是让目标进程加载我们的动态链接库。Android是基于linux的系统,而linux中的dlopen()函数用来打开一个动态库,并将其加载到进程的地址空间。所以注入过程要完成的事情只有一个:调用dlopen()。
这个调用过程必须发生在目标进程,也就是让目标进程去调用dlopen(),这样动态链接库才能加载到目标进程中。那么该怎么实现控制目标进程去调用dlopen()呢?在Linux中有一个系统调用ptrace(),它可以查看和修改目标进程的内存和寄存器中的数据,就像在gdb调试的时候可以中断程序,然后查看修改变量一样。通过ptrace()系统调用,就可以修改pc寄存器以控制目标进程的执行流程。例如可以修改pc寄存器的值为ptrace()函数的地址,并在寄存器和栈中提前写好参数,这样就实现了使目标进程打开动态链接库的目的。不仅如此,在注入完成后,还可以让pc寄存器等于注入进去的动态链接库的函数来调用该函数。
ptrace系统调用和waitpid
以下内容主要翻译自linux man-pages。
ptrace()系统调用为一个进程(追踪进程)提供了一种观察和控制其它进程(被追踪进程)执行的方法,它可以查看并修改被追踪进程的内存和寄存器。对于一个多线程的进程来说,它的每一个线程都可以被单独追踪。
追踪可以采取以下两种方式进行,一是父进程fork子进程且子进程发出PTRACE_TRACEME请求时,父进程可以追踪子进程。二是当一个进程使用PTRACE_ATTACH 或 PTRACE_SEIZE请求时,它可以追踪其它进程。
当被追踪者产生一个信号时(SIGKILL信号除外),被追踪者暂停,追踪者会在waitpid中被唤醒,然后追踪者可以使用各种ptrace请求来查看和修改被追踪者的执行状态。当追踪者完成追踪后,可以通过PTRACE_DETACH请求来结束被追踪者的追踪状态。
ptrace的函数声明如下
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
request代表请求,pid代表被追踪者的进程,addr是读或写的地址,data是读或写的数据。其中addr和data可以为NULL,视request而定。
接下来介绍注入过程中用到的几个ptrace请求和waitpid函数,以及ptrace过程中stop的几种情况。
waitpid
该函数用来等待子进程的状态变化:进程终止,进程收到信号停下,进程收到信号继续运行。当子进程状态变化时,该函数会立马返回。声明如下:
pid_t waitpid(pid_t pid, int *wstatus, int options);
成功时返回子进程的id或0(设置了WHONANG),发生错误则返回-1.
options可以取以下任意几项的或:
- WNOHANG:如果没有子进程则马上返回
- WUNTRACED:当子进程停下时返回。当子进程被追踪时,即使没有指明该项,子进程停下时也会返回。
- WCONTINUED:当子进程收到SIGONT信号继续运行时返回。
当wstatus不取NULL时,它可以用来存储状态信息。信息可以通过以下几个宏来获得(用法:宏(*wstatus)???):
- WIFEXITED(wstatus):当子进程正常结束时返回真。 (即exit(3) 或 _exit(2)或从main正常返回)。
- WEXITSTATUS(wstatus):返回子进程的结束状态,只有当WIFEXITED返回真时才可以使用该宏。
- WIFSIGNALED(wstatus):当子进程被信号终止时返回
- WTERMSIG(wstatus):返回使子进程终止的信号数字。只有当WIFSIGNALED返回真时才可以使用该宏。
- WCOREDUMP(wstatus):暂时用不到,跳过。
- WIFSTOPPED(wstatus):当子进程被信号停下(非终止)时返回真。只有当options中有 WUNTRACED或子进程正在被追踪时才可能发生这种情况。
- WSTOPSIG(wstatus):返回使子进程停下的信号的数字,只有当WIFSTOPPED返回真时才可以使用该宏。
- WIFCONTINUED(wstatus):当子进程收到SIGONT信号继续运行时返回真。应该与WCONTINUED的options搭配使用。
PTRACE_ATTACH
附在pid代表的进程上,追踪该进程。追踪者发送SIGSTOP信号给被追踪者,但被追踪者并不是马上停止。追踪者可以用waitpid来等待被追踪者停止。使用该请求时addr参数和data参数会被忽略。
PTRACE_CONT
让被追踪进程开始运行。如果data参数非0,则代表发送给被追踪者的信号。忽略addr参数。
PTRACE_SYSCALL, PTRACE_SINGLESTEP
PTRACE_SYSCALL让被追踪进程开始运行,但会在下次进入或退出系统调用时停止。PTRACE_SINGLESTEP则会让被追踪者在执行完下条指令后停下。另外,被追踪者会在收到信号时也会停下。非0的data参数同样代表信号。忽略addr参数。
PTRACE_DETACH
和PTRACE_CONT一样,让被追踪进程开始运行,同时停止追踪状态。在linux下,无论任何形式进行的追踪状态都可以用该请求来停止。忽略addr参数。
PTRACE_DETACH请求需要被追踪者处于停止状态,若被追踪者正在运行,需要先发送SIGSTOP信号使其停下。
PTRACE_GETREGS, PTRACE_GETFPREGS
获取被追踪者的通用或浮点寄存器。获取的值会存储到data指向的数据结构(pt_regs)中。忽略addr参数。
并不是所有的架构都有该请求。
PTRACE_GETREGSET
读取被追踪者寄存器。addr参数决定读取寄存器的类型。如果addr是NT_PRSTATUS,则读取通用寄存器。如果addr是NT_foo,则读取浮点或向量寄存器(如果有的话)。data参数指向iovec类型:
struct iovec {
void __user * iov_base;
__kernel_size_t iov_len;
};
该类型描述存储寄存器信息的指针(pt_regs*类型)和长度。内核会更新长度来告知实际读取的长度。
PTRACE_POKETEXT, PTRACE_POKEDATA
往被追踪者的内存写入一个字大小的数据(32位中一个字为4字节,64位中为6字节)。要用到addr和data参数。
PTRACE_PEEKTEXT, PTRACE_PEEKDATA
从被追踪者内存读取一个字大小数据。该数据会被作为返回值,所以只用到addr参数,忽略data参数。
signal-delivery-stop
当一个进程收到任何除了SIGKILL的信号时,它会随机选择一个线程来处理该信号(可以通过tgkill来选择处理的线程)。如果该线程正在被追踪,那么该线程就会停下,把信号交给追踪者。追踪者可以选择隐藏该信号(suppression),也可以把信号重新交给被追踪者(signal injection)。
注意,只有signal-delivery-stop才可以发生signal injection,即在PTRACE_CONT中重新发送一个信号。如果是其它ptrace-stop,可能会忽略PTRACE_CONT中的data参数。
当发生signal-delivery-stop时,追踪者waitpid函数的返回状态可以使宏WIFSTOPPED(status)为真,使宏WSTOPSIG(status)返回信号编号。具体见前面waitpid部分。
group-stop
当一个被追踪者收到停止信号( SIGSTOP, SIGTSTP, SIGTTIN, or SIGTTOU)时,进入group-stop。可以通过ptrace(PTRACE_GETSIGINFO, pid, 0, &siginfo)来区别group-stop和其它stop。
syscall-stops
syscall-stops分为syscall-enter-stop和syscall-exit-stop。当使用PTRACE_SYSEMU请求来继续运行被追踪者时,被追踪者会在进入系统调用前停下,发生syscall-enter-stop。
当使用PTRACE_SYSCALL时,不仅会发生syscall-enter-stop,被追踪者还会再系统调用完成后停下,发生syscall-exit-stop。
信号引起的进程停止signal-delivery-stop不会发生在syscall-enter-stop和syscall-exit-stop间,只会发生在syscall-exit-stop后。
syscall-stops同样可以用waitpid的宏判断。具体见前面waipid部分。
函数地址获取方法
既然要让进程跳转到dlopen()函数执行,首先要获取目标进程中dlopen()函数的地址。dlopen()函数,dlsym()函数以及其它函数都是以动态链接的方式链接到程序中,它们都位于进程中的某个模块,例如dlopen就位于linker模块,如下图。
可以看到,如果知道linker模块的地址和dlopen()相对linker模块的偏移,即图中的①和②,那么就可以将偏移和地址相加来得到dlopen()在进程中的地址。
这些信息可以从proc/pid/maps这个文件中得到。proc/pid/maps显示进程映射了的内存区域。在这个文件中,可以读到一条条以下格式的信息:
b6fc1000-b6fd1000 r-xp 00000000 b3:26 336 /system/bin/linker
从左到右依次为动态库的:起止地址 权限 偏移 设备 i结点号 名字。
首先在本进程(追踪进程)中得到dlopen的地址,然后再查看本进程的proc文件,找到linker模块的起止地址。把dlopen的地址减去linker模块的地址就得到了图中的②。
而图中的①可以直接查询被追踪进程的的proc文件,查到linker模块的起止地址,即图中的①。
先看②具体是怎么获取的。
find_module_name_by_addr
首先是打开proc/pid/maps文件,代码如下:
void* find_module_name_by_addr(pid_t pid, void* address, char *const module_name)
{
char proc_map_name[32];
void* return_addr = NULL;
char line[256];
if (pid < 0)
{
sprintf(proc_map_name, "/proc/self/maps");
}
else
{
sprintf(proc_map_name, "/proc/%d/maps", pid);
}
FILE *proc_map = fopen(proc_map_name, "r");
//...
当pid为-1时,代表本进程,所以文件名为/proc/self/maps。否则则是/proc/pid/maps。
然后需要对从该文件中读取的信息进行处理。在这个文件中,可以读到一行行以下格式的信息:
b6fc1000-b6fd1000 r-xp 00000000 b3:26 336 /system/bin/linker
其中有用的信息是起始地址和模块名。为了处理该行信息,需要使用strtoul和strsep和函数。
#include<string.h>
char *strsep(char **stringp, const char *delim);
//以delim字符串中的任意一个字符作为分割符,分割stringp,只分割一次,
//分成两个字符串。会将位于stringp中的分割字符换成'\0',stringp会被更
//新为'\0'的后一个位置(的地址),即指向第二个字符串,返回原stringp
//指向位置,即返回第一个字符串。
//如果没找到分割符,则stringp设为NULL,返回原字符串。
unsigned long int strtoul(const char *str, char **endptr, int base)
//把参数 str 所指向的字符串根据给定的 base 转换为一个无符号长整数。
//str是字符串,base是基数,若endptr不为NULL,其值由函数设置为 str
//中数值后的下一个字符。
//返回转换后的长整数,如果没有执行有效的转换,则返回一个零值。
通过strsep(&line_ptr, " "),就能以取得空格前面的字符串,并且使line_ptr指向空格后的字符串,方便下一次处理。函数后面部分的读取和处理代码如下:
//...
//fgets() will read the '\n' and end with '\0'
while (fgets(line, sizeof(line), proc_map))
{
char line_log[256];
strcpy(line_log,line);
char *line_ptr = line;
//format of the str in line: b6fc1000-b6fd1000 r-xp 00000000 b3:26 336 /system/bin/linker
void* start_addr = (void*)strtoul(strsep(&line_ptr, "-"), NULL, 16);
void* end_addr = (void*)strtoul(strsep(&line_ptr, " "), NULL, 16);
//if find the module that contains our address, obtains its name
if (address >= start_addr && address < end_addr)
{
strsep(&line_ptr, " "); //skip permission
strsep(&line_ptr, " "); //skip offset
strsep(&line_ptr, " "); //skip dev
strsep(&line_ptr, " "); //skip inode
while (*line_ptr == ' ')
{
line_ptr++; //skip blank
}
if(module_name!=NULL)
strcpy(module_name, strsep(&line_ptr,"\n")); //elimate last '\n'
return_addr = start_addr;
break;
}
}
fclose(proc_map);
return return_addr;
}
这样就得到了模块在本进程的起始地址和名字了。接下来只要将函数地址-起始地址,就得到了偏移地址,即前面图中的②。
find_module_addr_by_name
和find_module_name_by_addr基本相同,只不过由于需要获取的是模块在目标进程中的地址,所以从/proc/pid/maps中查找的信息和find_module_name_by_addr有所不同。代码如下:
void* find_module_addr_by_name(pid_t pid, char *module_name)
{
char proc_map_name[32];
void* return_addr = NULL;
char line[256];
if (pid < 0)
{
sprintf(proc_map_name, "/proc/self/maps");
}
else
{
sprintf(proc_map_name, "/proc/%d/maps", pid);
}
FILE *proc_map = fopen(proc_map_name, "r");
//...
打开文件的部分和find_module_name_by_addr一模一样。继续看后面。
while (fgets(line, sizeof(line), proc_map))
{
char *line_ptr = line;
//format: b6fc1000-b6fd1000 r-xp 00000000 b3:26 336 /system/bin/linker
void* start_addr = (void*)strtoul(strsep(&line_ptr, "-"), NULL, 16);
strsep(&line_ptr, " "); //skip end_address
strsep(&line_ptr, " "); //skip permission
strsep(&line_ptr, " "); //skip offset
strsep(&line_ptr, " "); //skip dev
strsep(&line_ptr, " "); //skip inode
while (*line_ptr == ' ')
{
line_ptr++; //skip blank
}
//if the name does not exist, continue;
if (*line_ptr == '\n')
{
continue;
}
if (strstr(line_ptr, module_name) != NULL)
{
//several module have the same name,
//only the first one have executing permission
//so directly break when find the first one
return_addr = start_addr;
break;
}
}
fclose(proc_map);
return return_addr;
}
就是读取一行信息,匹配模块名,若模块名相同则返回其地址。
需要注意的是,在读取/proc/pid/maps过程中,可能读到好几个有相同模块名的信息。它们分别代表同一模块的代码块,数据块等。要找的是其中有执行权限的代码模块,也就是找到的第一条信息。
这样就找到了前面图中的①。接下来只要将上面两个函数得到的结果进行计算,实现②+①。
get_fun_remote_addr
void* get_fun_remote_addr(pid_t pid, void* func_local_addr)
{
char module_name[64];
void* local_module_start = find_module_name_by_addr(-1, func_local_addr, module_name);
if (local_module_start == NULL)
{
return NULL;
}
void* remote_module_start = find_module_addr_by_name(pid, module_name);
if (remote_module_start == NULL)
{
return NULL;
}
return func_local_addr - local_module_start + remote_module_start;
}
该函数调用了find_module_name_by_addr和find_module_addr_by_name,根据前面的方法,计算出了函数在目标进程中的地址。使用时只需要传入目标进程的pid和需要得到的函数在本进程的地址即可。
可以用该函数获取dlopen和dlsym的地址。
pt_regs、ptrace_getregs和ptrace_setregs
在前面介绍过,PTRACE_GETRES和PTRACE_GETREGSET请求可以读取被追踪者的寄存器。用函数ptrace_getregs来封装该ptrace调用。在介绍该函数之前,先看用来接收寄存器信息的结构pt_regs。查看ptace.h的方法在文章开头已经介绍过了。
pt_regs在32位arm和64位arm的区别
//32位的ptrace.h
struct pt_regs {
long uregs[18];
};
#define ARM_cpsr uregs[16]
#define ARM_pc uregs[15]
#define ARM_lr uregs[14]
#define ARM_sp uregs[13]
#define ARM_ip uregs[12]
#define ARM_fp uregs[11]
#define ARM_r10 uregs[10]
#define ARM_r9 uregs[9]
#define ARM_r8 uregs[8]
#define ARM_r7 uregs[7]
#define ARM_r6 uregs[6]
#define ARM_r5 uregs[5]
#define ARM_r4 uregs[4]
#define ARM_r3 uregs[3]
#define ARM_r2 uregs[2]
#define ARM_r1 uregs[1]
#define ARM_r0 uregs[0]
#define ARM_ORIG_r0 uregs[17]
#define ARM_VFPREGS_SIZE (32 * 8 + 4)
//64位的ptrace.h
struct user_pt_regs {
__u64 regs[31];
__u64 sp;
__u64 pc;
__u64 pstate;
};
64位arm的ptrace.h相比于32位arm有以下区别:
- pt_regs变为了user_pt_regs。
- 成员long uregs[18]变成了__u64 regs[31]。
- 没有了诸如ARM_r0等宏,sp,pc,cpsr等寄存器信息不存在在数组中,而是单独存在user_pt_regs成员变量中。
- 另外,arm32和arm64的调用约定也有区别。
为了屏蔽以上区别,需要在ptrace封装函数所在的源文件开头加入以下宏定义。
#if defined(__aarch64__)
#define pt_regs user_pt_regs
#define uregs regs
#define ARM_pc pc
#define ARM_sp sp
#define ARM_cpsr pstate
#define ARM_lr regs[30]
#define ARM_r0 regs[0]
#define PTRACE_GETREGS PTRACE_GETREGSET
#define PTRACE_SETREGS PTRACE_SETREGSET
#endif
其中PTRACE_GETREGS被换成了PTRACE_GETREGSET。这可能是由于PTRACE_GETREGS不存在64位arm架构中。
而ARM_lr替换成r30是64位Arm程序调用约定规定的。
ptrace_getregs
根据前面对PTRACE_GETREGS的介绍,在32位ARM处理机中获取寄存器信息是非常简单的。直接:
ptrace(PTRACE_GETREGS, pid, NULL, regs)
即可。其中regs是传进来的pt_regs类型指针。
64位ARM处理机的代码要稍微麻烦一些,因为要使用PTRACE_GETREGSET请求,而该请求需要指明接收的寄存器类型,并且使用iovec类型而非pt_regs类型来接收寄存器信息。实现代码如下:
int ptrace_getregs(pid_t pid, struct pt_regs * regs)
{
#if defined (__aarch64__)
int regset = NT_PRSTATUS;//general-purpose registers
struct iovec ioVec;
ioVec.iov_base = regs;
ioVec.iov_len = sizeof(*regs);
if (ptrace(PTRACE_GETREGSET, pid, (void*)regset, &ioVec) < 0) {
perror("[-] ptrace_getregs: Can not get register values");
return -1;
}
return 0;
#else
if (ptrace(PTRACE_GETREGS, pid, NULL, regs) < 0) {
perror("[-] ptrace_getregs: Can not get register values");
return -1;
}
return 0;
#endif
}
ptrace_setregs
ptrace_setregs和ptrace_setregs的代码大同小异,只不过换了ptrace的请求。
int ptrace_setregs(pid_t pid, struct pt_regs * regs)
{
#if defined (__aarch64__)
int regset = NT_PRSTATUS;
struct iovec ioVec;
ioVec.iov_base = regs;
ioVec.iov_len = sizeof(*regs);
if (ptrace(PTRACE_GETREGSET, pid, (void*)regset, &ioVec) < 0) {
perror("[-] ptrace_setregs: Can not get register values");
return -1;
}
return 0;
#else
if (ptrace(PTRACE_SETREGS, pid, NULL, regs) < 0) {
perror("[-] ptrace_setregs: Can not set register values");
return -1;
}
return 0;
#endif
}
ptrace_writedata
PTRACE_POKEDATA请求每次只能写一个字的数据,ptrace_writedata对其进行了封装,该函数实现了往目标进程写入任意大小数据的功能。
int ptrace_writedata(pid_t pid, uint8_t *dest, uint8_t *data, size_t size)
{
long i, j, remain;
uint8_t *laddr;
size_t bytes_width = sizeof(long);
union u {
long val;
char chars[sizeof(long)];
} d;
j = size / bytes_width;
remain = size % bytes_width;
laddr = data;
for (i = 0; i < j; i ++) {
memcpy(d.chars, laddr, bytes_width);
ptrace(PTRACE_POKETEXT, pid, dest, d.val);
dest += bytes_width;
laddr += bytes_width;
}
if (remain > 0) {
d.val = ptrace(PTRACE_PEEKTEXT, pid, dest, 0);
for (i = 0; i < remain; i ++) {
d.chars[i] = *laddr ++;
}
ptrace(PTRACE_POKETEXT, pid, dest, d.val);
}
return 0;
}
值得注意的是里面union的定义。由于要兼容32位和64位,所以union的长度是sizeof(long),long在32位机器中是4字节,在64位中是8字节。为了方便复制数据,union的另一个成员是char指针,char指针每次指向一个字节的空间。
后面PEEKTEXT是为了在写入长度非整数字长的情况下,防止覆盖目标进程空间后面的数据。
ptrace_call
当想要在目标进程中调用函数时,首先需要将参数写入目标进程空间,然后设置函数跳转地址和返回地址。这里面牵扯到很多arm调用标准的内容。想看具体内容可去arm developer搜索相关文档。要用到的主要调用约定如下:
- arm32中,函数的前四个参数放到r0-r3寄存器。其它参数从右到左入栈。被调用者实现栈平衡,返回值存放在 r0 中。
- arm32中,函数的前八个参数放到r0-r7寄存器。其它参数从右到左入栈。被调用者实现栈平衡,返回值存放在 r0 中。
- lr寄存器存放返回地址。
往目标进程写入参数的代码如下:
int ptrace_call(pid_t pid, uintptr_t addr, long *params, int num_params, struct pt_regs* regs)
{
int i;
#if defined(__arm__)
int num_param_registers = 4;
#elif defined(__aarch64__)
int num_param_registers = 8;
#endif
for (i = 0; i < num_params && i < num_param_registers; i ++) {
regs->uregs[i] = params[i];
}
if (i < num_params) {
regs->ARM_sp -= (num_params - i) * sizeof(long) ;
ptrace_writedata(pid, (uint8_t *)regs->ARM_sp, (uint8_t *)¶ms[i], (num_params - i) * sizeof(long));
}
regs->ARM_pc = addr;
if (regs->ARM_pc & 1) {
/* thumb */
regs->ARM_pc &= (~1u);
regs->ARM_cpsr |= CPSR_T_MASK;
} else {
/* arm */
regs->ARM_cpsr &= ~CPSR_T_MASK;
}
//...
然后要解决函数返回的问题。希望在函数调用完之后被追踪者暂停,回到追踪者也就是本进程。也就是说希望函数调用完之后被追踪者发生signal-delivery-stop。
可以这样实现,令lr寄存器为0。当函数执行完后,目标进程会跳转回lr寄存器中的地址。但0地址是不允许访问的,于是便会产生SIGSEVG错误,目标进程暂停,信号传回追踪进程。
为了防止其它信号的干扰,还需要设置一个ptrace_continue的循环。当收到的状态不是0xb7f(7f代表被追踪者暂停,b代表SIGSEVG的信号序号11)时,隐藏信号,继续让目标进程执行,直到收到SIGSEVG为止。代码如下:
//...
regs->ARM_lr = 0;
if (ptrace_setregs(pid, regs) == -1
|| ptrace_continue(pid) == -1) {
printf("error\n");
return -1;
}
int stat = 0;
waitpid(pid, &stat, WUNTRACED);
while (stat != 0xb7f) {
if (ptrace_continue(pid) == -1) {
printf("[-] error\n");
return -1;
}
waitpid(pid, &stat, WUNTRACED);
}
return 0;
}
ptrace_call结束后,函数的返回值会存储到pt_regs的r0中。
ptrace_mmap
接下来解决函数参数的问题。当函数参数是整型,浮点型等这类数据时,可以直接在ptrace_call中把参数写入寄存器或栈中。但如果参数是指针类型时,在把指针写入寄存器或栈之前首先得在目标进程空间中准备好指针指向的数据。
例如当想要传递一个字符串(char*)给函数时,首先需要在目标进程找一块空间,把字符串复制到这一块空间中,再把字符串地址传给函数。
通过在目标进程中调用mmap的方式来开辟空间。先看mmap的声明:
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
mmap在进程的虚拟空间中创建一块映射区。length参数指明映射的长度,addr为0时由系统决定映射区的起始地址,该地址会作为返回值返回。prot参数描述该映射区的内存保护属性,它可以为PROT_NONE或以下值的或:
PROT_EXEC 页内容可能被执行
PROT_READ 页内容可能被读
PROT_WRITE 页内容可能被写
PROT_NONE 页不可访问
flags参数决定该映射区的更新是否会导致文件或其它进程映射区的更新。这里主要看匿名映射的参数MAP_ANONYMOUS。该参数说明映射区不与任何文件关联,匿名映射下,fd参数会被忽略(或为-1),offset参数应该为0。映射区的空间会被0初始化。
也就是说,当想要分配空间时,需要以
//length代表空间的大小
mmap(0, length, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE, 0, 0);
的形式在目标进程中调用mmap。知道参数后,构造参数然后直接ptrace_call即可。代码如下:
void* find_space_by_mmap(int target_pid, int size){
struct pt_regs regs;
if (ptrace_getregs(target_pid, ®s) == -1)
return 0;
long parameters[10];
/* call mmap */
parameters[0] = 0; // addr
parameters[1] = size; // size
parameters[2] = PROT_READ | PROT_WRITE | PROT_EXEC; // prot
parameters[3] = MAP_ANONYMOUS | MAP_PRIVATE; // flags
parameters[4] = 0; //fd
parameters[5] = 0; //offset
void* remote_mmap_addr = get_fun_remote_addr(target_pid,(void*)mmap);
if (remote_mmap_addr == NULL) {
LOGE("[-] Get Remote mmap address fails.\n");
return 0;
}
LOGI("[+] start to call mmap, size of space to be mapped is %d",size);
ptrace_call(target_pid, (uint32_t) remote_mmap_addr, parameters, 6, ®s);
ptrace_getregs(target_pid, ®s);
LOGD("[+] Target process returned from mmap, return r0=%x, pc=%x, \n", regs.ARM_r0, regs.ARM_pc);
return regs.ARM_pc == 0 ? (void *) regs.ARM_r0 : 0;
}
最后返回了分配的空间的地址。
ptrace_dlopen
接下来开始正式执行注入,即在目标进程调用dlopen加载动态库。先看dlopen:
void *dlopen(const char *filename, int flags);
dlopen会加载filename命名的动态库,并返回加载后的句柄handle。handle可以被dlsym,dlclose等函数使用。filename中的/会被视为路径。
flags参数必须包含以下二值之一:
RTLD_LAZY 延迟绑定,只有当符号第一次使用时才开始绑定(PLT机制)
RTLD_NOW 立即绑定,模块被加载时即完成所有的函数绑定工作。
flags还可以选择或上RTLD_GLOBAL,它表示将被加载的模块的全局符号合并到进程的全局符号表中,使得以后加载的模块可以使用这些符号。
因此,要以以下形式:
dlopen(filename, RTLD_NOW | RTLD_GLOBAL);
在目标进程中调用dlopen。
需要注意的是,由于传入的filename是字符串,首先需要find_space_by_mmap在目标进程找到一块空间来存放该字符串,然后再传递字符串指针和flag调用dlopen。代码如下:
void* ptrace_dlopen(pid_t pid, void* dlopen_addr, char* filename)
{
struct pt_regs regs;
ptrace_getregs(pid, ®s);
long params[2];
size_t filename_len = strlen(filename) + 1;
LOGI("[+] Try to find space for string %s in target process",filename);
void* filename_addr;
filename_addr= find_space_by_mmap(pid, filename_len);
LOGI("[+] String \"%s\" address %x",filename, filename_addr);
if (filename_addr == NULL ) {
LOGE("[-] Call Remote mmap fails.");
return NULL;
}
ptrace_writedata(pid, (uint8_t*)filename_addr, (uint8_t*)filename, filename_len);
params[0] = (long)filename_addr; //filename pointer
params[1] = RTLD_NOW | RTLD_GLOBAL; // flag
if (dlopen_addr == NULL) {
return NULL;
}
ptrace_call(pid, (uint32_t) dlopen_addr, params, 2, ®s);
ptrace_getregs(pid, ®s);
LOGD("[+] Target process returned from dlopen, return r0=%x, pc=%x, \n", regs.ARM_r0, regs.ARM_pc);
return regs.ARM_pc == 0 ? (void *) regs.ARM_r0 : NULL;
}
该函数将handle值返回。handle值可以被dlsym使用,用来查找该动态库中某个符号(函数)的地址。ptrace_dlsym的实现方法和ptrace_dlopen基本一样:
void *ptrace_dlsym(pid_t target_pid, void *remote_dlsym_address, void *handle, char *symbol_name)
{
LOGI("[+] start to dlsym %s",symbol_name);
struct pt_regs regs;
ptrace_getregs(target_pid, ®s);
long params[2];
size_t name_len=strlen((char*)symbol_name)+1;
LOGI("[+] Try to find space for string %s in target process",symbol_name);
void* symbol_name_address=find_space_by_mmap(target_pid,name_len);
LOGI("[+] string %s address %p",symbol_name,symbol_name_address);
if(symbol_name_address==NULL)
{
LOGE("[-] Call Remote mmap fails.");
return NULL;
}
ptrace_writedata(target_pid,(uint8_t*)symbol_name_address,(uint8_t*)symbol_name,name_len);
params[0]=(long)handle;
params[1]=(long)symbol_name_address;
ptrace_call(target_pid,remote_dlsym_address,params,2,®s);
ptrace_getregs(target_pid,®s);
LOGI("[+] Target process returned from dlysm, return r0=%x,pc=%x,\n",regs.ARM_r0,regs.ARM_pc);
return regs.ARM_pc==0?(void*)regs.ARM_r0:NULL;
}
至此,注入代码的主要原理和逻辑就结束了。但要真的成功实现注入,尤其是在高版本的android系统中实现注入,还需要做一些其它的准备工作。
find_pid_of获取pid
从前面的代码可以看到,进程注入需要目标进程的pid。最直接的pid获取方法是在adb shell中使用ps指令来查询某个进程的pid。但这样太过麻烦,每次注入都要人工查询。实际上只要知道进程的名字,就可以直接在程序中取得其pid。还是通过proc文件系统。
在proc/pid/cmdline中可以取得该进程的命令行参数,因而遍历proc下所有进程的cmdline,找到和进程名匹配的命令行参数,其pid就是要找的pid。
int find_pid_of(const char *process_name)
{
int id;
pid_t pid = -1;
DIR* dir;
FILE *fp;
char filename[32];
char cmdline[256];
struct dirent * entry;
if (process_name == NULL)
return -1;
dir = opendir("/proc");
if (dir == NULL)
return -1;
while((entry = readdir(dir)) != NULL) {
id = atoi(entry->d_name);//read the name in /proc and try to turn it to number
if (id != 0) {//if the name can be converted number, it represents the pid of process
sprintf(filename, "/proc/%d/cmdline", id);
fp = fopen(filename, "r");
if (fp) {
fgets(cmdline, sizeof(cmdline), fp);
fclose(fp);
LOGD("[d] cmdline: %s",cmdline);
//strcmp(process_name, cmdline)
//as we use strstr instead of strcmp,the process_name should be as accurate as
//possible, otherwise it may find the pid of other process that have the similar name.
if (strstr(cmdline,process_name) != NULL) {
// process found //
LOGI("[+] find the process, process name is %s, pid is %d",cmdline,id);
pid = id;
break;
}
}
}
}
closedir(dir);
return pid;
}
popen关闭selinux
为了方便注入,建议直接把selinux关闭。可以在adb shell中使用setenforce 0关闭selinux,也可以在程序中使用popen关闭。popen定义如下:
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
根据linux man pages,popen fork一个进程来执行shell命令,并且通过管道与本进程通信。由于管道是单向的,所以type参数只能是读(“r")或者只能是写(“w”)。command参数是包含shell命令的字符串指针。
popen的返回值是一个只能被pclose关闭的标准I/O流,如果失败则返回NULL。pclose会等待相关进程结束然后返回结束状态,如果失败则返回-1。
所以关闭selinux只需要以下两行代码就好了。
FILE* fp = popen("setenforce 0","r");
pclose(fp);
解决dlopen对动态库的限制问题
尽管在前面找到了dlopen的地址并调用了该函数,但由于高版本Android系统对动态库的打开有所限制,所以这时候dlopen并不会成功,会返回0值。具体什么限制可以去看android源码。根据源码,可以找到以下两个解决方法。
如果要注入的进程是某个app,那么只要把动态库放到app的目录下就好了。具体放在哪个目录可以看log,看dlopen的异常信息中指明的default_library_paths等是什么。
还有另外一种方法是把32位的动态库放到/system/lib,把64位的动态库放到system/lib64中(两个目录都要放),然后在/vendor/etc/public.libraries.txt或者/etc/public.libraries.txt中加入的动态库名。前者在我的手机上不奏效,每次重启后该文件自动恢复。后者在我的手机上是可行的。使用这种方法后,重启手机,然后在adb shell中cat /proc/zygote_pid/maps |grep so名,就可以看到的动态库。之后再在注入程序中dlopen就可以正常找到动态库了。
在第二种方法中,由于要修改/system目录下和/etc目录下的文件,需要重新挂载这两个目录。命令如下:
然后就可以adb push把文件push到里面了。如果失败,可能是adb的权限不够,可以先把文件push到/data/local/tmp中,再在shell中以root的权限mv到/system目录下。