不一

ptrace API和Android下zygote动态库注入

字数统计: 3.5k阅读时长: 14 min
2020/04/04 Share

上一次涉及安卓的项目写的是Python,这次轮到C了。远离JAVA,拥抱幸福。

事前

ptrace介绍

ptrace就是一个API,或者说就是一个函数,其函数定义如下(来自Linux man文档):

1
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

参数和返回值的意义:

  • request:请求类型,告诉ptrace要进行的操作。就像110是一个API,110的request有抢劫报警、盗窃报警或者拌面里有芹菜报警等。一些常用ptrace requst如下:
    • PTRACE_ATTACH:附载目标进程,然后挂起目标进程;
    • PTRACE_SEIZE:附载目标进程,但不挂起目标进程
    • PTRACE_INTERRUPT:挂起目标进程,适合配合PTRACE_SEIZE使用;
    • PTRACE_CONT:继续运行被挂起的目标进程;
    • PTRACE_GETREGSPTRACE_SETREGS:读取目标进程的寄存器和写入目标进程寄存器;
    • PTRACE_PEEKDATAPTRACE_POKEDATA:从目标进程的指定内存地址读取和写入数据。
  • pid:就是要进行操作的目标进程的pid了。
  • addr:提供一个目标进程的地址,在PEEKDATA和POKEDATA的时候需要,用于操作指定内存地址的数据。其他时候传NULL即可。
  • data:在SETREGS和GETREGS的时候提供用于读写的寄存器数据;在PEEKDATA和POKEDATA的时候提供用于内存读写的数据。其他时候传NULL即可。

zygote

具体关于zygote的介绍可以参考伟大的搜索引擎,在这里只需要搞定一个问题——为什么要注入zygote?
因为zygote是安卓app的父进程,每当一个app启动时其内存空间都是fork自zygote的。正如有其父必有其子,Linux下的父进程和子进程间也存在类似继承的关系,因为fork可以简单理解为复制。因此只要是zygote加载的动态库a.so,其子进程(即所有app)都有加载这个动态库a.so。而注入zygote也就可以达到一个全局hook的效果。
在安卓shell中查询zygote的pid,是一个较小的数(本例中是245),因为是在系统初始化时期启动的一个进程:

1
2
# ps | grezygote
root 245 1 908128 52504 ffffffff 40086698 S zygote

环境搭建

MacOS

  • 由于开发环境在MacOS上,而需要面向安卓编译,因此需要基于NDK使用交叉编译技术。MacOS下安装NDK可以基于brew:
1
brew cask intall android-ndk

然后在配置NDK的环境变量,在~/.zshrc中添加:

1
2
3
4
# NDK目录
export NDK=/usr/local/Caskroom/android-ndk/21/android-ndk-r21
# NDK提供的交叉编译工具所在路径
export PATH=$PATH:$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin

source ~/.zshrc之后测验一下实验是否安装成功:

1
armv7a-linux-androideabi19-clang --version

  • 安装adb,同样也是基于brew的一键安装:
1
brew cask install android-platform-tools

安卓

使用的是Android 4.4.4版本的国产安卓手机,CPU架构为32位arm,因此使用的NDK交叉编译工具为armv7a-linux-androideabi19-clang,其他版本和架构的话需要选择其他对应工具。
手机需要root。
由于要注入zygote,所以需要关闭SELinux。把手机通过usb插上Mac之后,使用adb进行操作:

1
2
3
4
5
6
7
8
# 通过adb进入安卓的shell
adb shell
# 切换到root用户
su root
# 查看当前的SELinux状态,默认是Enforcing(开启状态)的
getenforce
# 关闭SELinnux
setenforce 0

事中

步骤零:#include

方便起见,把本工程所需使用到的头文件都罗列一下。
另外额外说明,这些头文件是MacOS的头文件,而编译的目标是安卓,头文件里面的一些内容可能不一样,所以不要在意IDE的查错功能(可以关掉就最好)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <sys/types.h>
#include <dirent.h>
#include <sys/syscall.h>
#include <sys/ptrace.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>

步骤一:ptrace附载zygote进程

直接使用ptrace API进行附载进程即可,其实pid是zygote进程的pid,可以通过ps命令获取,也可以通过代码遍历/proc目录查找到zygote的pid。后者的代码可以查看附录代码。

1
2
3
4
if (ptrace(PTRACE_SEIZE, pid, NULL, NULL) < 0) { 
printf("Ptrace seize error\n");
return -1;
}

步骤二:调用zygote进程中的malloc获取一段可读写的内存

分步骤如下:

  1. 找到zygote进程中malloc函数的地址;
  2. 布置malloc函数需要的参数;
  3. 调用zygote进程中的malloc函数;
  4. 获取malloc函数调用的返回值;
  5. 让zygote回到原来的运行流程中,不能打乱人家原来的运行状态。

1. 获取zygote进程中malloc函数的地址

理论基础是动态库的加载。malloc函数由libc.so提供,libc.so是一个动态库,意味着操作系统中所有的进程使用的是同一个libc.so,这就好办了。

  1. 假设malloc在我们进程中的地址为a
  2. 假设libc.so在我们进程中的加载地址为c1
  3. 那么malloc函数在libc.so中的地址为a - c1
  4. 假设libc.so在zygote进程中的加载地址为c2
  5. 那么malloc在zygote进程中的加载地址为c2 + (a - c1)

关于获取malloc函数在我们进程中的地址:

1
void *address_malloc_self = malloc;

关于获取libc.so在进程中加载地址的获取可以通过查询/proc/[进程pid]/maps文件,也不过是一个文件IO和字符串匹配的过程,代码在附录。

2. 布置malloc函数需要的参数

32位arm架构下的参数传递规则是前4个参数通过寄存器r0-r3传递,之后的参数从右往左依次入栈。
malloc函数只有一个参数,指示要分配内存的大小,因此只需要布置一个寄存器即r0。具体代码过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 挂起目标进程
if (ptrace(PTRACE_INTERRUPT, pid, NULL, NULL) < 0) {
printf("ptrace interrupt error\n");
return NULL;
}

// 捕获目标进程挂起的消息和状态
waitpid(pid, &status, WUNTRACED);
// 解析目标进程状态,用于DEBUG
parseStatus(status);

// 保存目标进程当前寄存器状态,用于之后还原运行状态
struct pt_regs savedRegs;
// 要布置的寄存器
struct pt_regs newRegs;
// 获取目标进程寄存器状态
if (ptrace(PTRACE_GETREGS, pid, NULL, &savedRegs) < 0) {
printf("Save regs error\n");
return NULL;
}
// 拷贝一份寄存器状态:saveRegs -> newRegs
memcpy(&newRegs, &savedRegs, sizeof(pt_regs));

// 传递malloc参数,只需要设置r0寄存器
newRegs.ARM_r0 = 0x100;

3. 调用zygote进程中的malloc函数

修改目标进程的运行流程,涉及到两个寄存器:

  • pc寄存器:存储待运行的指令地址;
  • lr寄存器:存储返回地址,当遇到return时会用到。
    使用下面代码布置两个寄存器:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // function就是目标进程中malloc函数的地址
    newRegs.ARM_pc = (long)function;
    newRegs.ARM_lr = 0;

    // 判断是否进行arm和thumb的运行状态切换
    if (newRegs.ARM_pc & 1) {
    // 目标地址是thumb指令
    newRegs.ARM_pc &= (~1u);
    newRegs.ARM_cpsr |= CPSR_T_MASK;
    } else {
    // 目标地址是arm指令
    newRegs.ARM_cpsr &= ~CPSR_T_MASK;
    }

    // 修改目标进程寄存器
    if (ptrace(PTRACE_SETREGS, pid, NULL, &newRegs) < 0) {
    printf("set regs error\n");
    return NULL;
    }

    // 目标进程继续运行
    if (ptrace(PTRACE_CONT, pid, NULL, NULL) < 0) {
    printf("continue error\n");
    return NULL;
    }

上诉代码设置了pc为malloc函数的地址,因此目标进程会跳转到malloc函数去执行;又设置了lr为0,这样目标进程执行完malloc之后会返回到0地址执行。0地址当然是不能执行的,会报出Segmentation Fault错误,我们的进程呢就捕获到这个错误,然后进行返回值获取和恢复原有运行流程的操作。
关于arm和thumb的史话也另外参考搜索引擎了。

4. 获取malloc函数调用的返回值

由上一个步骤可知目标进程会触发错误,我们就使用waitpid函数去捕获这个错误并加以正确的引导。

1
2
3
// 捕获跳转到0的异常
waitpid(pid, &status, WUNTRACED);
parseStatus();

由于此时malloc已经执行完成,而其返回值会存储在r0中,所以需要再次获取目标进程的寄存器数据。

1
2
3
4
5
6
7
8
9
10
11
// 获取返回值
pt_regs retRegs;
// 这个用来存储返回值的变量
void *ret;
if (ptrace(PTRACE_GETREGS, pid, NULL, &retRegs) < 0) {
printf("read regs error\n");
return NULL;
} else {
// 从r0寄存器中获取返回值
ret = (void *)retRegs.ARM_r0;
}

5. 让zygote回到原来的运行流程中

malloc已经执行完了,分配得到的内存地址也获取了,是时候功成身退让zygote回到最初的运行状态去了。

1
2
3
4
5
6
7
8
9
10
11
// 把最初保存下来的寄存器数据再写入到目标进程中
if (ptrace(PTRACE_SETREGS, pid, NULL, &savedRegs) < 0) {
printf("restore regs error\n");
return NULL;
}

// 继续运行
if (ptrace(PTRACE_CONT, pid, NULL, NULL) < 0) {
printf("continue error\n");
return NULL;
}

步骤三:使用dlopen函数加载自定义so

dlopen是用于加载so的一个函数,其定义如下:

1
void *dlopen(const char *filename, int flag);

参数和返回值定义:

  • filename:要加载的动态库文件名,比如”/system/lib/libc.so”
  • flag:选项参数,具体参考Linux man
  • 返回值:加载so得到的句柄(handle),用于dlsym函数获取动态库中函数地址。

依然进行步骤分解:

  1. 获取zygote进程中的dlopen函数地址;
  2. 布置dlopen需要的参数;
  3. 调用zygote进程中的dlopen;
  4. 获取dlopen调用的返回值;
  5. 复原zygote原有运行流程。

1. 获取zygote进程中dlopen函数地址

基本过程和步骤二中获取malloc地址差不多,但是有一个大坑:dlopen不是libc.so中的函数。因此相比起计算malloc地址时使用libc.so的加载地址,计算dlopen时使用/system/bin/linker的加载地址进行计算:

2. 布置dlopen需要的参数

malloc的参数直接通过r0传递一个整数就可以了,而dlopen的第一个参数是字符串指针,因此需要现在zygote的内存中找个地方存放这个字符串,然后再把字符串的地址放在r0中,其他的流程就和调用malloc一样的。所幸刚刚通过malloc获取了这么可以存放字符串的地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 挂起目标进程,捕获状态
..

// ptrace后面的两个参数派上用场的,在POKEDATA指令下,每次往目标进程的目标地址上写入4byte数据
char text[] = “/system/lib/liba.so”;
int length = strlen(text);
int i = 0;
// 转换成指向4byte的指针
long *data = (long *)text;
while (i < length / 4 + 2) {
// 一次往目标进程写入4byte数据,address就是之前malloc的返回值
if (ptrace(PTRACE_POKEDATA, pid, (long)address + i * 4, data[i]) < 0) {
printf("write text error\n");
return;
} else {
i++;
}
}

把字符串放好之后就可以布置寄存器了。

1
2
3
4
// 第一个参数,文件名字符串地址,就是malloc的返回值
newRegs.ARM_r0 = (long)malloc_ret_address;
// 第二个参数,选项
newRegs.ARM_r1 = RTLD_NOW | RTLD_GLOBAL;

3.4.5. 同步骤二

dlopen执行完之后就已经完成了自定义so的加载,可以在/proc/[目标进程pid]/maps中看到自定义so。

步骤四:使用dlsym函数调用自定义so中函数

步骤四其实没有什么新东西了,基本就是上诉步骤的复习。其中dlsym函数的定义如下:

1
void *dlsym(void *handle, const char *symbol);

参数与返回值解释:

  • handle:dlopen函数加载的so的句柄,也就是dlopen的返回值;
  • symbol:要获取的so中函数的符号(不是函数名,这个要看so中符号表);
  • 返回值:如果找到函数的话,就返回这个函数在目标进程中的地址。

与调用dlopen类似,调用dlsym的过程如下,但不详述了:

  1. 调用malloc获取一段可写的内存;
  2. 用上诉的内存存放symbol参数;
  3. 布置函数调用寄存器;
  4. 调用dlsym函数、获取返回值、恢复运行状态;
  5. 调用dlsym返回的自定义函数。

步骤五:善始善终

挥一挥衣袖不带走一片云彩。

1
ptrace(PTRACE_DETACH, pid, NULL, NULL);

事后

运行效果

除了上诉查看zygote的maps,发现加载了自定义so之外,手动运行一个app,发现其也加载了自定义的so。

代码附录

通过进程名查询pid:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int findPID(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);
if (id != 0) {
sprintf(filename, "/proc/%d/cmdline", id);
fp = fopen(filename, "r");
if (fp) {
fgets(cmdline, sizeof(cmdline), fp);
fclose(fp);

if (strcmp(process_name, cmdline) == 0) {
/* process found */
pid = id;
break;
}
}
}
}

closedir(dir);
return pid;
}

获取远程函数地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 获取内存中模块基址
// 通过查询/proc/<pid>/mmap
void *getModuleBaseAddress(pid_t pid, char *moduleName) {
FILE *fp;
char filename[50] = {0};
if (pid < 0) {
sprintf(filename, "/proc/self/maps");
} else {
sprintf(filename, "/proc/%d/maps", pid);
}

void *moduleBaseAddress = 0;

fp = fopen(filename, "r");
if (fp != NULL) {
// 打开maps成功
// 获取模块加载基址
char *modulePath, *mapFileLineItem;
char line[1024] = {0};
char szProcessInfo[1024] = {0};
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, moduleName))
{
mapFileLineItem = strtok(line, " \t"); // 基址信息
char *address = strtok(line, "-");
moduleBaseAddress = (void *)strtoul(address, NULL, 16 );

if ((int)moduleBaseAddress == 0x8000)
moduleBaseAddress = 0;

break;
}
}
fclose(fp);
} else {
printf("file open error\n");
}

return moduleBaseAddress;
}

// 获取函数地址
// 远程函数地址 = 本进程函数的绝对地址 - 本进程模块加载地址 + 远程进程模块的加载地址
void *getFunctionAddress(pid_t pid, void *localAddress, char *moduleName) {
void *localBase, *remoteBase;

// char moduleName[] = "libc.so";
// 本进程libc基址
void *addressBaseLibcSelf = getModuleBaseAddress(-1, moduleName);
// 远进程libc基址
void *addressBaseLibcTracee = getModuleBaseAddress(pid, moduleName);

return (void *)((unsigned long)localAddress - (unsigned long)addressBaseLibcSelf + (unsigned long)addressBaseLibcTracee);
}

捕获状态解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// status是全局变量
int status;

void parseStatus() {
if (WIFSTOPPED(status)) {
// 进程暂停
// 获取进程暂停信号编号
int signal = WSTOPSIG(status);
printf("Tracee stopped sigal: %d\n", signal);
} else if (WIFEXITED(status)) {
printf("Tracee exited\n");
} else if (WIFSIGNALED(status)) {
printf("Tracee signaled\n");
} else {
printf("Status: %p\n", (void *)status);
}
}

CATALOG
  1. 1. 事前
    1. 1.1. ptrace介绍
    2. 1.2. zygote
    3. 1.3. 环境搭建
      1. 1.3.1. MacOS
      2. 1.3.2. 安卓
  2. 2. 事中
    1. 2.1. 步骤零:#include
    2. 2.2. 步骤一:ptrace附载zygote进程
    3. 2.3. 步骤二:调用zygote进程中的malloc获取一段可读写的内存
      1. 2.3.1. 1. 获取zygote进程中malloc函数的地址
      2. 2.3.2. 2. 布置malloc函数需要的参数
      3. 2.3.3. 3. 调用zygote进程中的malloc函数
      4. 2.3.4. 4. 获取malloc函数调用的返回值
      5. 2.3.5. 5. 让zygote回到原来的运行流程中
    4. 2.4. 步骤三:使用dlopen函数加载自定义so
      1. 2.4.1. 1. 获取zygote进程中dlopen函数地址
      2. 2.4.2. 2. 布置dlopen需要的参数
      3. 2.4.3. 3.4.5. 同步骤二
    5. 2.5. 步骤四:使用dlsym函数调用自定义so中函数
    6. 2.6. 步骤五:善始善终
  3. 3. 事后
    1. 3.1. 运行效果
    2. 3.2. 代码附录